` in the browser.
10 | */
11 | const Box = forwardRef
>(
12 | ({children, ...style}, ref) => {
13 | return (
14 |
26 | {children}
27 |
28 | );
29 | },
30 | );
31 |
32 | Box.displayName = 'Box';
33 |
34 | export default Box;
35 |
--------------------------------------------------------------------------------
/src/components/ErrorOverview.tsx:
--------------------------------------------------------------------------------
1 | import * as fs from 'node:fs';
2 | import {cwd} from 'node:process';
3 | import React from 'react';
4 | import StackUtils from 'stack-utils';
5 | import codeExcerpt, {type CodeExcerpt} from 'code-excerpt';
6 | import Box from './Box.js';
7 | import Text from './Text.js';
8 |
9 | // Error's source file is reported as file:///home/user/file.js
10 | // This function removes the file://[cwd] part
11 | const cleanupPath = (path: string | undefined): string | undefined => {
12 | return path?.replace(`file://${cwd()}/`, '');
13 | };
14 |
15 | const stackUtils = new StackUtils({
16 | cwd: cwd(),
17 | internals: StackUtils.nodeInternals(),
18 | });
19 |
20 | type Props = {
21 | readonly error: Error;
22 | };
23 |
24 | export default function ErrorOverview({error}: Props) {
25 | const stack = error.stack ? error.stack.split('\n').slice(1) : undefined;
26 | const origin = stack ? stackUtils.parseLine(stack[0]!) : undefined;
27 | const filePath = cleanupPath(origin?.file);
28 | let excerpt: CodeExcerpt[] | undefined;
29 | let lineWidth = 0;
30 |
31 | if (filePath && origin?.line && fs.existsSync(filePath)) {
32 | const sourceCode = fs.readFileSync(filePath, 'utf8');
33 | excerpt = codeExcerpt(sourceCode, origin.line);
34 |
35 | if (excerpt) {
36 | for (const {line} of excerpt) {
37 | lineWidth = Math.max(lineWidth, String(line).length);
38 | }
39 | }
40 | }
41 |
42 | return (
43 |
44 |
45 |
46 | {' '}
47 | ERROR{' '}
48 |
49 |
50 | {error.message}
51 |
52 |
53 | {origin && filePath && (
54 |
55 |
56 | {filePath}:{origin.line}:{origin.column}
57 |
58 |
59 | )}
60 |
61 | {origin && excerpt && (
62 |
63 | {excerpt.map(({line, value}) => (
64 |
65 |
66 |
71 | {String(line).padStart(lineWidth, ' ')}:
72 |
73 |
74 |
75 |
80 | {' ' + value}
81 |
82 |
83 | ))}
84 |
85 | )}
86 |
87 | {error.stack && (
88 |
89 | {error.stack
90 | .split('\n')
91 | .slice(1)
92 | .map(line => {
93 | const parsedLine = stackUtils.parseLine(line);
94 |
95 | // If the line from the stack cannot be parsed, we print out the unparsed line.
96 | if (!parsedLine) {
97 | return (
98 |
99 | -
100 |
101 | {line}
102 |
103 |
104 | );
105 | }
106 |
107 | return (
108 |
109 | -
110 |
111 | {parsedLine.function}
112 |
113 |
114 | {' '}
115 | ({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
116 | {parsedLine.column})
117 |
118 |
119 | );
120 | })}
121 |
122 | )}
123 |
124 | );
125 | }
126 |
--------------------------------------------------------------------------------
/src/components/FocusContext.ts:
--------------------------------------------------------------------------------
1 | import {createContext} from 'react';
2 |
3 | export type Props = {
4 | readonly activeId?: string;
5 | readonly add: (id: string, options: {autoFocus: boolean}) => void;
6 | readonly remove: (id: string) => void;
7 | readonly activate: (id: string) => void;
8 | readonly deactivate: (id: string) => void;
9 | readonly enableFocus: () => void;
10 | readonly disableFocus: () => void;
11 | readonly focusNext: () => void;
12 | readonly focusPrevious: () => void;
13 | readonly focus: (id: string) => void;
14 | };
15 |
16 | // eslint-disable-next-line @typescript-eslint/naming-convention
17 | const FocusContext = createContext({
18 | activeId: undefined,
19 | add() {},
20 | remove() {},
21 | activate() {},
22 | deactivate() {},
23 | enableFocus() {},
24 | disableFocus() {},
25 | focusNext() {},
26 | focusPrevious() {},
27 | focus() {},
28 | });
29 |
30 | FocusContext.displayName = 'InternalFocusContext';
31 |
32 | export default FocusContext;
33 |
--------------------------------------------------------------------------------
/src/components/Newline.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export type Props = {
4 | /**
5 | * Number of newlines to insert.
6 | *
7 | * @default 1
8 | */
9 | readonly count?: number;
10 | };
11 |
12 | /**
13 | * Adds one or more newline (\n) characters. Must be used within components.
14 | */
15 | export default function Newline({count = 1}: Props) {
16 | return {'\n'.repeat(count)};
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/Spacer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Box from './Box.js';
3 |
4 | /**
5 | * A flexible space that expands along the major axis of its containing layout.
6 | * It's useful as a shortcut for filling all the available spaces between elements.
7 | */
8 | export default function Spacer() {
9 | return ;
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/Static.tsx:
--------------------------------------------------------------------------------
1 | import React, {useMemo, useState, useLayoutEffect, type ReactNode} from 'react';
2 | import {type Styles} from '../styles.js';
3 |
4 | export type Props = {
5 | /**
6 | * Array of items of any type to render using a function you pass as a component child.
7 | */
8 | readonly items: T[];
9 |
10 | /**
11 | * Styles to apply to a container of child elements. See for supported properties.
12 | */
13 | readonly style?: Styles;
14 |
15 | /**
16 | * Function that is called to render every item in `items` array.
17 | * First argument is an item itself and second argument is index of that item in `items` array.
18 | * Note that `key` must be assigned to the root component.
19 | */
20 | readonly children: (item: T, index: number) => ReactNode;
21 | };
22 |
23 | /**
24 | * `` component permanently renders its output above everything else.
25 | * It's useful for displaying activity like completed tasks or logs - things that
26 | * are not changing after they're rendered (hence the name "Static").
27 | *
28 | * It's preferred to use `` for use cases like these, when you can't know
29 | * or control the amount of items that need to be rendered.
30 | *
31 | * For example, [Tap](https://github.com/tapjs/node-tap) uses `` to display
32 | * a list of completed tests. [Gatsby](https://github.com/gatsbyjs/gatsby) uses it
33 | * to display a list of generated pages, while still displaying a live progress bar.
34 | */
35 | export default function Static(props: Props) {
36 | const {items, children: render, style: customStyle} = props;
37 | const [index, setIndex] = useState(0);
38 |
39 | const itemsToRender: T[] = useMemo(() => {
40 | return items.slice(index);
41 | }, [items, index]);
42 |
43 | useLayoutEffect(() => {
44 | setIndex(items.length);
45 | }, [items.length]);
46 |
47 | const children = itemsToRender.map((item, itemIndex) => {
48 | return render(item, index + itemIndex);
49 | });
50 |
51 | const style: Styles = useMemo(
52 | () => ({
53 | position: 'absolute',
54 | flexDirection: 'column',
55 | ...customStyle,
56 | }),
57 | [customStyle],
58 | );
59 |
60 | return (
61 |
62 | {children}
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/StderrContext.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import {createContext} from 'react';
3 |
4 | export type Props = {
5 | /**
6 | * Stderr stream passed to `render()` in `options.stderr` or `process.stderr` by default.
7 | */
8 | readonly stderr: NodeJS.WriteStream;
9 |
10 | /**
11 | * Write any string to stderr, while preserving Ink's output.
12 | * It's useful when you want to display some external information outside of Ink's rendering and ensure there's no conflict between the two.
13 | * It's similar to ``, except it can't accept components, it only works with strings.
14 | */
15 | readonly write: (data: string) => void;
16 | };
17 |
18 | /**
19 | * `StderrContext` is a React context, which exposes stderr stream.
20 | */
21 | // eslint-disable-next-line @typescript-eslint/naming-convention
22 | const StderrContext = createContext({
23 | stderr: process.stderr,
24 | write() {},
25 | });
26 |
27 | StderrContext.displayName = 'InternalStderrContext';
28 |
29 | export default StderrContext;
30 |
--------------------------------------------------------------------------------
/src/components/StdinContext.ts:
--------------------------------------------------------------------------------
1 | import {EventEmitter} from 'node:events';
2 | import process from 'node:process';
3 | import {createContext} from 'react';
4 |
5 | export type Props = {
6 | /**
7 | * Stdin stream passed to `render()` in `options.stdin` or `process.stdin` by default. Useful if your app needs to handle user input.
8 | */
9 | readonly stdin: NodeJS.ReadStream;
10 |
11 | /**
12 | * Ink exposes this function via own `` to be able to handle Ctrl+C, that's why you should use Ink's `setRawMode` instead of `process.stdin.setRawMode`.
13 | * If the `stdin` stream passed to Ink does not support setRawMode, this function does nothing.
14 | */
15 | readonly setRawMode: (value: boolean) => void;
16 |
17 | /**
18 | * A boolean flag determining if the current `stdin` supports `setRawMode`. A component using `setRawMode` might want to use `isRawModeSupported` to nicely fall back in environments where raw mode is not supported.
19 | */
20 | readonly isRawModeSupported: boolean;
21 |
22 | readonly internal_exitOnCtrlC: boolean;
23 |
24 | readonly internal_eventEmitter: EventEmitter;
25 | };
26 |
27 | /**
28 | * `StdinContext` is a React context, which exposes input stream.
29 | */
30 | // eslint-disable-next-line @typescript-eslint/naming-convention
31 | const StdinContext = createContext({
32 | stdin: process.stdin,
33 | // eslint-disable-next-line @typescript-eslint/naming-convention
34 | internal_eventEmitter: new EventEmitter(),
35 | setRawMode() {},
36 | isRawModeSupported: false,
37 | // eslint-disable-next-line @typescript-eslint/naming-convention
38 | internal_exitOnCtrlC: true,
39 | });
40 |
41 | StdinContext.displayName = 'InternalStdinContext';
42 |
43 | export default StdinContext;
44 |
--------------------------------------------------------------------------------
/src/components/StdoutContext.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import {createContext} from 'react';
3 |
4 | export type Props = {
5 | /**
6 | * Stdout stream passed to `render()` in `options.stdout` or `process.stdout` by default.
7 | */
8 | readonly stdout: NodeJS.WriteStream;
9 |
10 | /**
11 | * Write any string to stdout, while preserving Ink's output.
12 | * It's useful when you want to display some external information outside of Ink's rendering and ensure there's no conflict between the two.
13 | * It's similar to ``, except it can't accept components, it only works with strings.
14 | */
15 | readonly write: (data: string) => void;
16 | };
17 |
18 | /**
19 | * `StdoutContext` is a React context, which exposes stdout stream, where Ink renders your app.
20 | */
21 | // eslint-disable-next-line @typescript-eslint/naming-convention
22 | const StdoutContext = createContext({
23 | stdout: process.stdout,
24 | write() {},
25 | });
26 |
27 | StdoutContext.displayName = 'InternalStdoutContext';
28 |
29 | export default StdoutContext;
30 |
--------------------------------------------------------------------------------
/src/components/Text.tsx:
--------------------------------------------------------------------------------
1 | import React, {type ReactNode} from 'react';
2 | import chalk, {type ForegroundColorName} from 'chalk';
3 | import {type LiteralUnion} from 'type-fest';
4 | import colorize from '../colorize.js';
5 | import {type Styles} from '../styles.js';
6 |
7 | export type Props = {
8 | /**
9 | * Change text color. Ink uses chalk under the hood, so all its functionality is supported.
10 | */
11 | readonly color?: LiteralUnion;
12 |
13 | /**
14 | * Same as `color`, but for background.
15 | */
16 | readonly backgroundColor?: LiteralUnion;
17 |
18 | /**
19 | * Dim the color (emit a small amount of light).
20 | */
21 | readonly dimColor?: boolean;
22 |
23 | /**
24 | * Make the text bold.
25 | */
26 | readonly bold?: boolean;
27 |
28 | /**
29 | * Make the text italic.
30 | */
31 | readonly italic?: boolean;
32 |
33 | /**
34 | * Make the text underlined.
35 | */
36 | readonly underline?: boolean;
37 |
38 | /**
39 | * Make the text crossed with a line.
40 | */
41 | readonly strikethrough?: boolean;
42 |
43 | /**
44 | * Inverse background and foreground colors.
45 | */
46 | readonly inverse?: boolean;
47 |
48 | /**
49 | * This property tells Ink to wrap or truncate text if its width is larger than container.
50 | * If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines.
51 | * If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off.
52 | */
53 | readonly wrap?: Styles['textWrap'];
54 |
55 | readonly children?: ReactNode;
56 | };
57 |
58 | /**
59 | * This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough.
60 | */
61 | export default function Text({
62 | color,
63 | backgroundColor,
64 | dimColor = false,
65 | bold = false,
66 | italic = false,
67 | underline = false,
68 | strikethrough = false,
69 | inverse = false,
70 | wrap = 'wrap',
71 | children,
72 | }: Props) {
73 | if (children === undefined || children === null) {
74 | return null;
75 | }
76 |
77 | const transform = (children: string): string => {
78 | if (dimColor) {
79 | children = chalk.dim(children);
80 | }
81 |
82 | if (color) {
83 | children = colorize(children, color, 'foreground');
84 | }
85 |
86 | if (backgroundColor) {
87 | children = colorize(children, backgroundColor, 'background');
88 | }
89 |
90 | if (bold) {
91 | children = chalk.bold(children);
92 | }
93 |
94 | if (italic) {
95 | children = chalk.italic(children);
96 | }
97 |
98 | if (underline) {
99 | children = chalk.underline(children);
100 | }
101 |
102 | if (strikethrough) {
103 | children = chalk.strikethrough(children);
104 | }
105 |
106 | if (inverse) {
107 | children = chalk.inverse(children);
108 | }
109 |
110 | return children;
111 | };
112 |
113 | return (
114 |
118 | {children}
119 |
120 | );
121 | }
122 |
--------------------------------------------------------------------------------
/src/components/Transform.tsx:
--------------------------------------------------------------------------------
1 | import React, {type ReactNode} from 'react';
2 |
3 | export type Props = {
4 | /**
5 | * Function which transforms children output. It accepts children and must return transformed children too.
6 | */
7 | readonly transform: (children: string, index: number) => string;
8 |
9 | readonly children?: ReactNode;
10 | };
11 |
12 | /**
13 | * Transform a string representation of React components before they are written to output.
14 | * For example, you might want to apply a gradient to text, add a clickable link or create some text effects.
15 | * These use cases can't accept React nodes as input, they are expecting a string.
16 | * That's what component does, it gives you an output string of its child components and lets you transform it in any way.
17 | */
18 | export default function Transform({children, transform}: Props) {
19 | if (children === undefined || children === null) {
20 | return null;
21 | }
22 |
23 | return (
24 |
28 | {children}
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/devtools-window-polyfill.ts:
--------------------------------------------------------------------------------
1 | // Ignoring missing types error to avoid adding another dependency for this hack to work
2 | import ws from 'ws';
3 |
4 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
5 | const customGlobal = global as any;
6 |
7 | // These things must exist before importing `react-devtools-core`
8 |
9 | // eslint-disable-next-line n/no-unsupported-features/node-builtins
10 | customGlobal.WebSocket ||= ws;
11 |
12 | customGlobal.window ||= global;
13 |
14 | customGlobal.self ||= global;
15 |
16 | // Filter out Ink's internal components from devtools for a cleaner view.
17 | // Also, ince `react-devtools-shared` package isn't published on npm, we can't
18 | // use its types, that's why there are hard-coded values in `type` fields below.
19 | // See https://github.com/facebook/react/blob/edf6eac8a181860fd8a2d076a43806f1237495a1/packages/react-devtools-shared/src/types.js#L24
20 | customGlobal.window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = [
21 | {
22 | // ComponentFilterElementType
23 | type: 1,
24 | // ElementTypeHostComponent
25 | value: 7,
26 | isEnabled: true,
27 | },
28 | {
29 | // ComponentFilterDisplayName
30 | type: 2,
31 | value: 'InternalApp',
32 | isEnabled: true,
33 | isValid: true,
34 | },
35 | {
36 | // ComponentFilterDisplayName
37 | type: 2,
38 | value: 'InternalAppContext',
39 | isEnabled: true,
40 | isValid: true,
41 | },
42 | {
43 | // ComponentFilterDisplayName
44 | type: 2,
45 | value: 'InternalStdoutContext',
46 | isEnabled: true,
47 | isValid: true,
48 | },
49 | {
50 | // ComponentFilterDisplayName
51 | type: 2,
52 | value: 'InternalStderrContext',
53 | isEnabled: true,
54 | isValid: true,
55 | },
56 | {
57 | // ComponentFilterDisplayName
58 | type: 2,
59 | value: 'InternalStdinContext',
60 | isEnabled: true,
61 | isValid: true,
62 | },
63 | {
64 | // ComponentFilterDisplayName
65 | type: 2,
66 | value: 'InternalFocusContext',
67 | isEnabled: true,
68 | isValid: true,
69 | },
70 | ];
71 |
--------------------------------------------------------------------------------
/src/devtools.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/order */
2 |
3 | // eslint-disable-next-line import/no-unassigned-import
4 | import './devtools-window-polyfill.js';
5 |
6 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
7 | // @ts-expect-error
8 | import devtools from 'react-devtools-core';
9 |
10 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call
11 | (devtools as any).connectToDevTools();
12 |
--------------------------------------------------------------------------------
/src/dom.ts:
--------------------------------------------------------------------------------
1 | import Yoga, {type Node as YogaNode} from 'yoga-layout';
2 | import measureText from './measure-text.js';
3 | import {type Styles} from './styles.js';
4 | import wrapText from './wrap-text.js';
5 | import squashTextNodes from './squash-text-nodes.js';
6 | import {type OutputTransformer} from './render-node-to-output.js';
7 |
8 | type InkNode = {
9 | parentNode: DOMElement | undefined;
10 | yogaNode?: YogaNode;
11 | internal_static?: boolean;
12 | style: Styles;
13 | };
14 |
15 | export type TextName = '#text';
16 | export type ElementNames =
17 | | 'ink-root'
18 | | 'ink-box'
19 | | 'ink-text'
20 | | 'ink-virtual-text';
21 |
22 | export type NodeNames = ElementNames | TextName;
23 |
24 | // eslint-disable-next-line @typescript-eslint/naming-convention
25 | export type DOMElement = {
26 | nodeName: ElementNames;
27 | attributes: Record;
28 | childNodes: DOMNode[];
29 | internal_transform?: OutputTransformer;
30 |
31 | // Internal properties
32 | isStaticDirty?: boolean;
33 | staticNode?: DOMElement;
34 | onComputeLayout?: () => void;
35 | onRender?: () => void;
36 | onImmediateRender?: () => void;
37 | } & InkNode;
38 |
39 | export type TextNode = {
40 | nodeName: TextName;
41 | nodeValue: string;
42 | } & InkNode;
43 |
44 | // eslint-disable-next-line @typescript-eslint/naming-convention
45 | export type DOMNode = T extends {
46 | nodeName: infer U;
47 | }
48 | ? U extends '#text'
49 | ? TextNode
50 | : DOMElement
51 | : never;
52 |
53 | // eslint-disable-next-line @typescript-eslint/naming-convention
54 | export type DOMNodeAttribute = boolean | string | number;
55 |
56 | export const createNode = (nodeName: ElementNames): DOMElement => {
57 | const node: DOMElement = {
58 | nodeName,
59 | style: {},
60 | attributes: {},
61 | childNodes: [],
62 | parentNode: undefined,
63 | yogaNode: nodeName === 'ink-virtual-text' ? undefined : Yoga.Node.create(),
64 | };
65 |
66 | if (nodeName === 'ink-text') {
67 | node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node));
68 | }
69 |
70 | return node;
71 | };
72 |
73 | export const appendChildNode = (
74 | node: DOMElement,
75 | childNode: DOMElement,
76 | ): void => {
77 | if (childNode.parentNode) {
78 | removeChildNode(childNode.parentNode, childNode);
79 | }
80 |
81 | childNode.parentNode = node;
82 | node.childNodes.push(childNode);
83 |
84 | if (childNode.yogaNode) {
85 | node.yogaNode?.insertChild(
86 | childNode.yogaNode,
87 | node.yogaNode.getChildCount(),
88 | );
89 | }
90 |
91 | if (node.nodeName === 'ink-text' || node.nodeName === 'ink-virtual-text') {
92 | markNodeAsDirty(node);
93 | }
94 | };
95 |
96 | export const insertBeforeNode = (
97 | node: DOMElement,
98 | newChildNode: DOMNode,
99 | beforeChildNode: DOMNode,
100 | ): void => {
101 | if (newChildNode.parentNode) {
102 | removeChildNode(newChildNode.parentNode, newChildNode);
103 | }
104 |
105 | newChildNode.parentNode = node;
106 |
107 | const index = node.childNodes.indexOf(beforeChildNode);
108 | if (index >= 0) {
109 | node.childNodes.splice(index, 0, newChildNode);
110 | if (newChildNode.yogaNode) {
111 | node.yogaNode?.insertChild(newChildNode.yogaNode, index);
112 | }
113 |
114 | return;
115 | }
116 |
117 | node.childNodes.push(newChildNode);
118 |
119 | if (newChildNode.yogaNode) {
120 | node.yogaNode?.insertChild(
121 | newChildNode.yogaNode,
122 | node.yogaNode.getChildCount(),
123 | );
124 | }
125 |
126 | if (node.nodeName === 'ink-text' || node.nodeName === 'ink-virtual-text') {
127 | markNodeAsDirty(node);
128 | }
129 | };
130 |
131 | export const removeChildNode = (
132 | node: DOMElement,
133 | removeNode: DOMNode,
134 | ): void => {
135 | if (removeNode.yogaNode) {
136 | removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode);
137 | }
138 |
139 | removeNode.parentNode = undefined;
140 |
141 | const index = node.childNodes.indexOf(removeNode);
142 | if (index >= 0) {
143 | node.childNodes.splice(index, 1);
144 | }
145 |
146 | if (node.nodeName === 'ink-text' || node.nodeName === 'ink-virtual-text') {
147 | markNodeAsDirty(node);
148 | }
149 | };
150 |
151 | export const setAttribute = (
152 | node: DOMElement,
153 | key: string,
154 | value: DOMNodeAttribute,
155 | ): void => {
156 | node.attributes[key] = value;
157 | };
158 |
159 | export const setStyle = (node: DOMNode, style: Styles): void => {
160 | node.style = style;
161 | };
162 |
163 | export const createTextNode = (text: string): TextNode => {
164 | const node: TextNode = {
165 | nodeName: '#text',
166 | nodeValue: text,
167 | yogaNode: undefined,
168 | parentNode: undefined,
169 | style: {},
170 | };
171 |
172 | setTextNodeValue(node, text);
173 |
174 | return node;
175 | };
176 |
177 | const measureTextNode = function (
178 | node: DOMNode,
179 | width: number,
180 | ): {width: number; height: number} {
181 | const text =
182 | node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node);
183 |
184 | const dimensions = measureText(text);
185 |
186 | // Text fits into container, no need to wrap
187 | if (dimensions.width <= width) {
188 | return dimensions;
189 | }
190 |
191 | // This is happening when is shrinking child nodes and Yoga asks
192 | // if we can fit this text node in a <1px space, so we just tell Yoga "no"
193 | if (dimensions.width >= 1 && width > 0 && width < 1) {
194 | return dimensions;
195 | }
196 |
197 | const textWrap = node.style?.textWrap ?? 'wrap';
198 | const wrappedText = wrapText(text, width, textWrap);
199 |
200 | return measureText(wrappedText);
201 | };
202 |
203 | const findClosestYogaNode = (node?: DOMNode): YogaNode | undefined => {
204 | if (!node?.parentNode) {
205 | return undefined;
206 | }
207 |
208 | return node.yogaNode ?? findClosestYogaNode(node.parentNode);
209 | };
210 |
211 | const markNodeAsDirty = (node?: DOMNode): void => {
212 | // Mark closest Yoga node as dirty to measure text dimensions again
213 | const yogaNode = findClosestYogaNode(node);
214 | yogaNode?.markDirty();
215 | };
216 |
217 | export const setTextNodeValue = (node: TextNode, text: string): void => {
218 | if (typeof text !== 'string') {
219 | text = String(text);
220 | }
221 |
222 | node.nodeValue = text;
223 | markNodeAsDirty(node);
224 | };
225 |
--------------------------------------------------------------------------------
/src/get-max-width.ts:
--------------------------------------------------------------------------------
1 | import Yoga, {type Node as YogaNode} from 'yoga-layout';
2 |
3 | const getMaxWidth = (yogaNode: YogaNode) => {
4 | return (
5 | yogaNode.getComputedWidth() -
6 | yogaNode.getComputedPadding(Yoga.EDGE_LEFT) -
7 | yogaNode.getComputedPadding(Yoga.EDGE_RIGHT) -
8 | yogaNode.getComputedBorder(Yoga.EDGE_LEFT) -
9 | yogaNode.getComputedBorder(Yoga.EDGE_RIGHT)
10 | );
11 | };
12 |
13 | export default getMaxWidth;
14 |
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | import {type ReactNode, type Key, type LegacyRef} from 'react';
2 | import {type Except} from 'type-fest';
3 | import {type DOMElement} from './dom.js';
4 | import {type Styles} from './styles.js';
5 |
6 | declare module 'react' {
7 | namespace JSX {
8 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
9 | interface IntrinsicElements {
10 | 'ink-box': Ink.Box;
11 | 'ink-text': Ink.Text;
12 | }
13 | }
14 | }
15 |
16 | declare namespace Ink {
17 | type Box = {
18 | internal_static?: boolean;
19 | children?: ReactNode;
20 | key?: Key;
21 | ref?: LegacyRef;
22 | style?: Except;
23 | };
24 |
25 | type Text = {
26 | children?: ReactNode;
27 | key?: Key;
28 | style?: Styles;
29 |
30 | // eslint-disable-next-line @typescript-eslint/naming-convention
31 | internal_transform?: (children: string, index: number) => string;
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/src/hooks/use-app.ts:
--------------------------------------------------------------------------------
1 | import {useContext} from 'react';
2 | import AppContext from '../components/AppContext.js';
3 |
4 | /**
5 | * `useApp` is a React hook, which exposes a method to manually exit the app (unmount).
6 | */
7 | const useApp = () => useContext(AppContext);
8 | export default useApp;
9 |
--------------------------------------------------------------------------------
/src/hooks/use-focus-manager.ts:
--------------------------------------------------------------------------------
1 | import {useContext} from 'react';
2 | import FocusContext, {type Props} from '../components/FocusContext.js';
3 |
4 | type Output = {
5 | /**
6 | * Enable focus management for all components.
7 | */
8 | enableFocus: Props['enableFocus'];
9 |
10 | /**
11 | * Disable focus management for all components. Currently active component (if there's one) will lose its focus.
12 | */
13 | disableFocus: Props['disableFocus'];
14 |
15 | /**
16 | * Switch focus to the next focusable component.
17 | * If there's no active component right now, focus will be given to the first focusable component.
18 | * If active component is the last in the list of focusable components, focus will be switched to the first component.
19 | */
20 | focusNext: Props['focusNext'];
21 |
22 | /**
23 | * Switch focus to the previous focusable component.
24 | * If there's no active component right now, focus will be given to the first focusable component.
25 | * If active component is the first in the list of focusable components, focus will be switched to the last component.
26 | */
27 | focusPrevious: Props['focusPrevious'];
28 |
29 | /**
30 | * Switch focus to the element with provided `id`.
31 | * If there's no element with that `id`, focus will be given to the first focusable component.
32 | */
33 | focus: Props['focus'];
34 | };
35 |
36 | /**
37 | * This hook exposes methods to enable or disable focus management for all
38 | * components or manually switch focus to next or previous components.
39 | */
40 | const useFocusManager = (): Output => {
41 | const focusContext = useContext(FocusContext);
42 |
43 | return {
44 | enableFocus: focusContext.enableFocus,
45 | disableFocus: focusContext.disableFocus,
46 | focusNext: focusContext.focusNext,
47 | focusPrevious: focusContext.focusPrevious,
48 | focus: focusContext.focus,
49 | };
50 | };
51 |
52 | export default useFocusManager;
53 |
--------------------------------------------------------------------------------
/src/hooks/use-focus.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useContext, useMemo} from 'react';
2 | import FocusContext from '../components/FocusContext.js';
3 | import useStdin from './use-stdin.js';
4 |
5 | type Input = {
6 | /**
7 | * Enable or disable this component's focus, while still maintaining its position in the list of focusable components.
8 | */
9 | isActive?: boolean;
10 |
11 | /**
12 | * Auto focus this component, if there's no active (focused) component right now.
13 | */
14 | autoFocus?: boolean;
15 |
16 | /**
17 | * Assign an ID to this component, so it can be programmatically focused with `focus(id)`.
18 | */
19 | id?: string;
20 | };
21 |
22 | type Output = {
23 | /**
24 | * Determines whether this component is focused or not.
25 | */
26 | isFocused: boolean;
27 |
28 | /**
29 | * Allows focusing a specific element with the provided `id`.
30 | */
31 | focus: (id: string) => void;
32 | };
33 |
34 | /**
35 | * Component that uses `useFocus` hook becomes "focusable" to Ink,
36 | * so when user presses Tab, Ink will switch focus to this component.
37 | * If there are multiple components that execute `useFocus` hook, focus will be
38 | * given to them in the order that these components are rendered in.
39 | * This hook returns an object with `isFocused` boolean property, which
40 | * determines if this component is focused or not.
41 | */
42 | const useFocus = ({
43 | isActive = true,
44 | autoFocus = false,
45 | id: customId,
46 | }: Input = {}): Output => {
47 | const {isRawModeSupported, setRawMode} = useStdin();
48 | const {activeId, add, remove, activate, deactivate, focus} =
49 | useContext(FocusContext);
50 |
51 | const id = useMemo(() => {
52 | return customId ?? Math.random().toString().slice(2, 7);
53 | }, [customId]);
54 |
55 | useEffect(() => {
56 | add(id, {autoFocus});
57 |
58 | return () => {
59 | remove(id);
60 | };
61 | }, [id, autoFocus]);
62 |
63 | useEffect(() => {
64 | if (isActive) {
65 | activate(id);
66 | } else {
67 | deactivate(id);
68 | }
69 | }, [isActive, id]);
70 |
71 | useEffect(() => {
72 | if (!isRawModeSupported || !isActive) {
73 | return;
74 | }
75 |
76 | setRawMode(true);
77 |
78 | return () => {
79 | setRawMode(false);
80 | };
81 | }, [isActive]);
82 |
83 | return {
84 | isFocused: Boolean(id) && activeId === id,
85 | focus,
86 | };
87 | };
88 |
89 | export default useFocus;
90 |
--------------------------------------------------------------------------------
/src/hooks/use-input.ts:
--------------------------------------------------------------------------------
1 | import {useEffect} from 'react';
2 | import parseKeypress, {nonAlphanumericKeys} from '../parse-keypress.js';
3 | import reconciler from '../reconciler.js';
4 | import useStdin from './use-stdin.js';
5 |
6 | /**
7 | * Handy information about a key that was pressed.
8 | */
9 | export type Key = {
10 | /**
11 | * Up arrow key was pressed.
12 | */
13 | upArrow: boolean;
14 |
15 | /**
16 | * Down arrow key was pressed.
17 | */
18 | downArrow: boolean;
19 |
20 | /**
21 | * Left arrow key was pressed.
22 | */
23 | leftArrow: boolean;
24 |
25 | /**
26 | * Right arrow key was pressed.
27 | */
28 | rightArrow: boolean;
29 |
30 | /**
31 | * Page Down key was pressed.
32 | */
33 | pageDown: boolean;
34 |
35 | /**
36 | * Page Up key was pressed.
37 | */
38 | pageUp: boolean;
39 |
40 | /**
41 | * Return (Enter) key was pressed.
42 | */
43 | return: boolean;
44 |
45 | /**
46 | * Escape key was pressed.
47 | */
48 | escape: boolean;
49 |
50 | /**
51 | * Ctrl key was pressed.
52 | */
53 | ctrl: boolean;
54 |
55 | /**
56 | * Shift key was pressed.
57 | */
58 | shift: boolean;
59 |
60 | /**
61 | * Tab key was pressed.
62 | */
63 | tab: boolean;
64 |
65 | /**
66 | * Backspace key was pressed.
67 | */
68 | backspace: boolean;
69 |
70 | /**
71 | * Delete key was pressed.
72 | */
73 | delete: boolean;
74 |
75 | /**
76 | * [Meta key](https://en.wikipedia.org/wiki/Meta_key) was pressed.
77 | */
78 | meta: boolean;
79 | };
80 |
81 | type Handler = (input: string, key: Key) => void;
82 |
83 | type Options = {
84 | /**
85 | * Enable or disable capturing of user input.
86 | * Useful when there are multiple useInput hooks used at once to avoid handling the same input several times.
87 | *
88 | * @default true
89 | */
90 | isActive?: boolean;
91 | };
92 |
93 | /**
94 | * This hook is used for handling user input.
95 | * It's a more convenient alternative to using `StdinContext` and listening to `data` events.
96 | * The callback you pass to `useInput` is called for each character when user enters any input.
97 | * However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`.
98 | *
99 | * ```
100 | * import {useInput} from 'ink';
101 | *
102 | * const UserInput = () => {
103 | * useInput((input, key) => {
104 | * if (input === 'q') {
105 | * // Exit program
106 | * }
107 | *
108 | * if (key.leftArrow) {
109 | * // Left arrow key pressed
110 | * }
111 | * });
112 | *
113 | * return …
114 | * };
115 | * ```
116 | */
117 | const useInput = (inputHandler: Handler, options: Options = {}) => {
118 | // eslint-disable-next-line @typescript-eslint/naming-convention
119 | const {stdin, setRawMode, internal_exitOnCtrlC, internal_eventEmitter} =
120 | useStdin();
121 |
122 | useEffect(() => {
123 | if (options.isActive === false) {
124 | return;
125 | }
126 |
127 | setRawMode(true);
128 |
129 | return () => {
130 | setRawMode(false);
131 | };
132 | }, [options.isActive, setRawMode]);
133 |
134 | useEffect(() => {
135 | if (options.isActive === false) {
136 | return;
137 | }
138 |
139 | const handleData = (data: string) => {
140 | const keypress = parseKeypress(data);
141 |
142 | const key = {
143 | upArrow: keypress.name === 'up',
144 | downArrow: keypress.name === 'down',
145 | leftArrow: keypress.name === 'left',
146 | rightArrow: keypress.name === 'right',
147 | pageDown: keypress.name === 'pagedown',
148 | pageUp: keypress.name === 'pageup',
149 | return: keypress.name === 'return',
150 | escape: keypress.name === 'escape',
151 | ctrl: keypress.ctrl,
152 | shift: keypress.shift,
153 | tab: keypress.name === 'tab',
154 | backspace: keypress.name === 'backspace',
155 | delete: keypress.name === 'delete',
156 | // `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false
157 | // but with option = true, so we need to take this into account here
158 | // to avoid breaking changes in Ink.
159 | // TODO(vadimdemedes): consider removing this in the next major version.
160 | meta: keypress.meta || keypress.name === 'escape' || keypress.option,
161 | };
162 |
163 | let input = keypress.ctrl ? keypress.name : keypress.sequence;
164 |
165 | if (nonAlphanumericKeys.includes(keypress.name)) {
166 | input = '';
167 | }
168 |
169 | // Strip meta if it's still remaining after `parseKeypress`
170 | // TODO(vadimdemedes): remove this in the next major version.
171 | if (input.startsWith('\u001B')) {
172 | input = input.slice(1);
173 | }
174 |
175 | if (
176 | input.length === 1 &&
177 | typeof input[0] === 'string' &&
178 | /[A-Z]/.test(input[0])
179 | ) {
180 | key.shift = true;
181 | }
182 |
183 | // If app is not supposed to exit on Ctrl+C, then let input listener handle it
184 | if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
185 | // @ts-expect-error TypeScript types for `batchedUpdates` require an argument, but React's codebase doesn't provide it and it works without it as exepected.
186 | reconciler.batchedUpdates(() => {
187 | inputHandler(input, key);
188 | });
189 | }
190 | };
191 |
192 | internal_eventEmitter?.on('input', handleData);
193 |
194 | return () => {
195 | internal_eventEmitter?.removeListener('input', handleData);
196 | };
197 | }, [options.isActive, stdin, internal_exitOnCtrlC, inputHandler]);
198 | };
199 |
200 | export default useInput;
201 |
--------------------------------------------------------------------------------
/src/hooks/use-stderr.ts:
--------------------------------------------------------------------------------
1 | import {useContext} from 'react';
2 | import StderrContext from '../components/StderrContext.js';
3 |
4 | /**
5 | * `useStderr` is a React hook, which exposes stderr stream.
6 | */
7 | const useStderr = () => useContext(StderrContext);
8 | export default useStderr;
9 |
--------------------------------------------------------------------------------
/src/hooks/use-stdin.ts:
--------------------------------------------------------------------------------
1 | import {useContext} from 'react';
2 | import StdinContext from '../components/StdinContext.js';
3 |
4 | /**
5 | * `useStdin` is a React hook, which exposes stdin stream.
6 | */
7 | const useStdin = () => useContext(StdinContext);
8 | export default useStdin;
9 |
--------------------------------------------------------------------------------
/src/hooks/use-stdout.ts:
--------------------------------------------------------------------------------
1 | import {useContext} from 'react';
2 | import StdoutContext from '../components/StdoutContext.js';
3 |
4 | /**
5 | * `useStdout` is a React hook, which exposes stdout stream.
6 | */
7 | const useStdout = () => useContext(StdoutContext);
8 | export default useStdout;
9 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export type {RenderOptions, Instance} from './render.js';
2 | export {default as render} from './render.js';
3 | export type {Props as BoxProps} from './components/Box.js';
4 | export {default as Box} from './components/Box.js';
5 | export type {Props as TextProps} from './components/Text.js';
6 | export {default as Text} from './components/Text.js';
7 | export type {Props as AppProps} from './components/AppContext.js';
8 | export type {Props as StdinProps} from './components/StdinContext.js';
9 | export type {Props as StdoutProps} from './components/StdoutContext.js';
10 | export type {Props as StderrProps} from './components/StderrContext.js';
11 | export type {Props as StaticProps} from './components/Static.js';
12 | export {default as Static} from './components/Static.js';
13 | export type {Props as TransformProps} from './components/Transform.js';
14 | export {default as Transform} from './components/Transform.js';
15 | export type {Props as NewlineProps} from './components/Newline.js';
16 | export {default as Newline} from './components/Newline.js';
17 | export {default as Spacer} from './components/Spacer.js';
18 | export type {Key} from './hooks/use-input.js';
19 | export {default as useInput} from './hooks/use-input.js';
20 | export {default as useApp} from './hooks/use-app.js';
21 | export {default as useStdin} from './hooks/use-stdin.js';
22 | export {default as useStdout} from './hooks/use-stdout.js';
23 | export {default as useStderr} from './hooks/use-stderr.js';
24 | export {default as useFocus} from './hooks/use-focus.js';
25 | export {default as useFocusManager} from './hooks/use-focus-manager.js';
26 | export {default as measureElement} from './measure-element.js';
27 | export type {DOMElement} from './dom.js';
28 |
--------------------------------------------------------------------------------
/src/ink.tsx:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import React, {type ReactNode} from 'react';
3 | import {throttle} from 'es-toolkit/compat';
4 | import ansiEscapes from 'ansi-escapes';
5 | import isInCi from 'is-in-ci';
6 | import autoBind from 'auto-bind';
7 | import signalExit from 'signal-exit';
8 | import patchConsole from 'patch-console';
9 | import {LegacyRoot} from 'react-reconciler/constants.js';
10 | import {type FiberRoot} from 'react-reconciler';
11 | import Yoga from 'yoga-layout';
12 | import reconciler from './reconciler.js';
13 | import render from './renderer.js';
14 | import * as dom from './dom.js';
15 | import logUpdate, {type LogUpdate} from './log-update.js';
16 | import instances from './instances.js';
17 | import App from './components/App.js';
18 |
19 | const noop = () => {};
20 |
21 | export type Options = {
22 | stdout: NodeJS.WriteStream;
23 | stdin: NodeJS.ReadStream;
24 | stderr: NodeJS.WriteStream;
25 | debug: boolean;
26 | exitOnCtrlC: boolean;
27 | patchConsole: boolean;
28 | waitUntilExit?: () => Promise;
29 | };
30 |
31 | export default class Ink {
32 | private readonly options: Options;
33 | private readonly log: LogUpdate;
34 | private readonly throttledLog: LogUpdate;
35 | // Ignore last render after unmounting a tree to prevent empty output before exit
36 | private isUnmounted: boolean;
37 | private lastOutput: string;
38 | private lastOutputHeight: number;
39 | private readonly container: FiberRoot;
40 | private readonly rootNode: dom.DOMElement;
41 | // This variable is used only in debug mode to store full static output
42 | // so that it's rerendered every time, not just new static parts, like in non-debug mode
43 | private fullStaticOutput: string;
44 | private exitPromise?: Promise;
45 | private restoreConsole?: () => void;
46 | private readonly unsubscribeResize?: () => void;
47 |
48 | constructor(options: Options) {
49 | autoBind(this);
50 |
51 | this.options = options;
52 | this.rootNode = dom.createNode('ink-root');
53 | this.rootNode.onComputeLayout = this.calculateLayout;
54 |
55 | this.rootNode.onRender = options.debug
56 | ? this.onRender
57 | : throttle(this.onRender, 32, {
58 | leading: true,
59 | trailing: true,
60 | });
61 |
62 | this.rootNode.onImmediateRender = this.onRender;
63 | this.log = logUpdate.create(options.stdout);
64 | this.throttledLog = options.debug
65 | ? this.log
66 | : (throttle(this.log, undefined, {
67 | leading: true,
68 | trailing: true,
69 | }) as unknown as LogUpdate);
70 |
71 | // Ignore last render after unmounting a tree to prevent empty output before exit
72 | this.isUnmounted = false;
73 |
74 | // Store last output to only rerender when needed
75 | this.lastOutput = '';
76 | this.lastOutputHeight = 0;
77 |
78 | // This variable is used only in debug mode to store full static output
79 | // so that it's rerendered every time, not just new static parts, like in non-debug mode
80 | this.fullStaticOutput = '';
81 |
82 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
83 | this.container = reconciler.createContainer(
84 | this.rootNode,
85 | LegacyRoot,
86 | null,
87 | false,
88 | null,
89 | 'id',
90 | () => {},
91 | () => {},
92 | // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
93 | // See https://github.com/facebook/react/blob/c0464aedb16b1c970d717651bba8d1c66c578729/packages/react-reconciler/src/ReactFiberReconciler.js#L236-L259
94 | () => {},
95 | () => {},
96 | null,
97 | );
98 |
99 | // Unmount when process exits
100 | this.unsubscribeExit = signalExit(this.unmount, {alwaysLast: false});
101 |
102 | if (process.env['DEV'] === 'true') {
103 | reconciler.injectIntoDevTools({
104 | bundleType: 0,
105 | // Reporting React DOM's version, not Ink's
106 | // See https://github.com/facebook/react/issues/16666#issuecomment-532639905
107 | version: '16.13.1',
108 | rendererPackageName: 'ink',
109 | });
110 | }
111 |
112 | if (options.patchConsole) {
113 | this.patchConsole();
114 | }
115 |
116 | if (!isInCi) {
117 | options.stdout.on('resize', this.resized);
118 |
119 | this.unsubscribeResize = () => {
120 | options.stdout.off('resize', this.resized);
121 | };
122 | }
123 | }
124 |
125 | resized = () => {
126 | this.calculateLayout();
127 | this.onRender();
128 | };
129 |
130 | resolveExitPromise: () => void = () => {};
131 | rejectExitPromise: (reason?: Error) => void = () => {};
132 | unsubscribeExit: () => void = () => {};
133 |
134 | calculateLayout = () => {
135 | // The 'columns' property can be undefined or 0 when not using a TTY.
136 | // In that case we fall back to 80.
137 | const terminalWidth = this.options.stdout.columns || 80;
138 |
139 | this.rootNode.yogaNode!.setWidth(terminalWidth);
140 |
141 | this.rootNode.yogaNode!.calculateLayout(
142 | undefined,
143 | undefined,
144 | Yoga.DIRECTION_LTR,
145 | );
146 | };
147 |
148 | onRender: () => void = () => {
149 | if (this.isUnmounted) {
150 | return;
151 | }
152 |
153 | const {output, outputHeight, staticOutput} = render(this.rootNode);
154 |
155 | // If output isn't empty, it means new children have been added to it
156 | const hasStaticOutput = staticOutput && staticOutput !== '\n';
157 |
158 | if (this.options.debug) {
159 | if (hasStaticOutput) {
160 | this.fullStaticOutput += staticOutput;
161 | }
162 |
163 | this.options.stdout.write(this.fullStaticOutput + output);
164 | return;
165 | }
166 |
167 | if (isInCi) {
168 | if (hasStaticOutput) {
169 | this.options.stdout.write(staticOutput);
170 | }
171 |
172 | this.lastOutput = output;
173 | this.lastOutputHeight = outputHeight;
174 | return;
175 | }
176 |
177 | if (hasStaticOutput) {
178 | this.fullStaticOutput += staticOutput;
179 | }
180 |
181 | if (this.lastOutputHeight >= this.options.stdout.rows) {
182 | this.options.stdout.write(
183 | ansiEscapes.clearTerminal + this.fullStaticOutput + output + '\n',
184 | );
185 | this.lastOutput = output;
186 | this.lastOutputHeight = outputHeight;
187 | this.log.sync(output);
188 | return;
189 | }
190 |
191 | // To ensure static output is cleanly rendered before main output, clear main output first
192 | if (hasStaticOutput) {
193 | this.log.clear();
194 | this.options.stdout.write(staticOutput);
195 | this.log(output);
196 | }
197 |
198 | if (!hasStaticOutput && output !== this.lastOutput) {
199 | this.throttledLog(output);
200 | }
201 |
202 | this.lastOutput = output;
203 | this.lastOutputHeight = outputHeight;
204 | };
205 |
206 | render(node: ReactNode): void {
207 | const tree = (
208 |
217 | {node}
218 |
219 | );
220 |
221 | // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
222 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call
223 | reconciler.updateContainerSync(tree, this.container, null, noop);
224 | // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
225 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call
226 | reconciler.flushSyncWork();
227 | }
228 |
229 | writeToStdout(data: string): void {
230 | if (this.isUnmounted) {
231 | return;
232 | }
233 |
234 | if (this.options.debug) {
235 | this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput);
236 | return;
237 | }
238 |
239 | if (isInCi) {
240 | this.options.stdout.write(data);
241 | return;
242 | }
243 |
244 | this.log.clear();
245 | this.options.stdout.write(data);
246 | this.log(this.lastOutput);
247 | }
248 |
249 | writeToStderr(data: string): void {
250 | if (this.isUnmounted) {
251 | return;
252 | }
253 |
254 | if (this.options.debug) {
255 | this.options.stderr.write(data);
256 | this.options.stdout.write(this.fullStaticOutput + this.lastOutput);
257 | return;
258 | }
259 |
260 | if (isInCi) {
261 | this.options.stderr.write(data);
262 | return;
263 | }
264 |
265 | this.log.clear();
266 | this.options.stderr.write(data);
267 | this.log(this.lastOutput);
268 | }
269 |
270 | // eslint-disable-next-line @typescript-eslint/ban-types
271 | unmount(error?: Error | number | null): void {
272 | if (this.isUnmounted) {
273 | return;
274 | }
275 |
276 | this.calculateLayout();
277 | this.onRender();
278 | this.unsubscribeExit();
279 |
280 | if (typeof this.restoreConsole === 'function') {
281 | this.restoreConsole();
282 | }
283 |
284 | if (typeof this.unsubscribeResize === 'function') {
285 | this.unsubscribeResize();
286 | }
287 |
288 | // CIs don't handle erasing ansi escapes well, so it's better to
289 | // only render last frame of non-static output
290 | if (isInCi) {
291 | this.options.stdout.write(this.lastOutput + '\n');
292 | } else if (!this.options.debug) {
293 | this.log.done();
294 | }
295 |
296 | this.isUnmounted = true;
297 |
298 | // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
299 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call
300 | reconciler.updateContainerSync(null, this.container, null, noop);
301 | // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
302 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call
303 | reconciler.flushSyncWork();
304 | instances.delete(this.options.stdout);
305 |
306 | if (error instanceof Error) {
307 | this.rejectExitPromise(error);
308 | } else {
309 | this.resolveExitPromise();
310 | }
311 | }
312 |
313 | async waitUntilExit(): Promise {
314 | this.exitPromise ||= new Promise((resolve, reject) => {
315 | this.resolveExitPromise = resolve;
316 | this.rejectExitPromise = reject;
317 | });
318 |
319 | return this.exitPromise;
320 | }
321 |
322 | clear(): void {
323 | if (!isInCi && !this.options.debug) {
324 | this.log.clear();
325 | }
326 | }
327 |
328 | patchConsole(): void {
329 | if (this.options.debug) {
330 | return;
331 | }
332 |
333 | this.restoreConsole = patchConsole((stream, data) => {
334 | if (stream === 'stdout') {
335 | this.writeToStdout(data);
336 | }
337 |
338 | if (stream === 'stderr') {
339 | const isReactMessage = data.startsWith('The above error occurred');
340 |
341 | if (!isReactMessage) {
342 | this.writeToStderr(data);
343 | }
344 | }
345 | });
346 | }
347 | }
348 |
--------------------------------------------------------------------------------
/src/instances.ts:
--------------------------------------------------------------------------------
1 | // Store all instances of Ink (instance.js) to ensure that consecutive render() calls
2 | // use the same instance of Ink and don't create a new one
3 | //
4 | // This map has to be stored in a separate file, because render.js creates instances,
5 | // but instance.js should delete itself from the map on unmount
6 |
7 | import type Ink from './ink.js';
8 |
9 | const instances = new WeakMap();
10 | export default instances;
11 |
--------------------------------------------------------------------------------
/src/log-update.ts:
--------------------------------------------------------------------------------
1 | import {type Writable} from 'node:stream';
2 | import ansiEscapes from 'ansi-escapes';
3 | import cliCursor from 'cli-cursor';
4 |
5 | export type LogUpdate = {
6 | clear: () => void;
7 | done: () => void;
8 | sync: (str: string) => void;
9 | (str: string): void;
10 | };
11 |
12 | const create = (stream: Writable, {showCursor = false} = {}): LogUpdate => {
13 | let previousLineCount = 0;
14 | let previousOutput = '';
15 | let hasHiddenCursor = false;
16 |
17 | const render = (str: string) => {
18 | if (!showCursor && !hasHiddenCursor) {
19 | cliCursor.hide();
20 | hasHiddenCursor = true;
21 | }
22 |
23 | const output = str + '\n';
24 | if (output === previousOutput) {
25 | return;
26 | }
27 |
28 | previousOutput = output;
29 | stream.write(ansiEscapes.eraseLines(previousLineCount) + output);
30 | previousLineCount = output.split('\n').length;
31 | };
32 |
33 | render.clear = () => {
34 | stream.write(ansiEscapes.eraseLines(previousLineCount));
35 | previousOutput = '';
36 | previousLineCount = 0;
37 | };
38 |
39 | render.done = () => {
40 | previousOutput = '';
41 | previousLineCount = 0;
42 |
43 | if (!showCursor) {
44 | cliCursor.show();
45 | hasHiddenCursor = false;
46 | }
47 | };
48 |
49 | render.sync = (str: string) => {
50 | const output = str + '\n';
51 | previousOutput = output;
52 | previousLineCount = output.split('\n').length;
53 | };
54 |
55 | return render;
56 | };
57 |
58 | const logUpdate = {create};
59 | export default logUpdate;
60 |
--------------------------------------------------------------------------------
/src/measure-element.ts:
--------------------------------------------------------------------------------
1 | import {type DOMElement} from './dom.js';
2 |
3 | type Output = {
4 | /**
5 | * Element width.
6 | */
7 | width: number;
8 |
9 | /**
10 | * Element height.
11 | */
12 | height: number;
13 | };
14 |
15 | /**
16 | * Measure the dimensions of a particular `` element.
17 | */
18 | const measureElement = (node: DOMElement): Output => ({
19 | width: node.yogaNode?.getComputedWidth() ?? 0,
20 | height: node.yogaNode?.getComputedHeight() ?? 0,
21 | });
22 |
23 | export default measureElement;
24 |
--------------------------------------------------------------------------------
/src/measure-text.ts:
--------------------------------------------------------------------------------
1 | import widestLine from 'widest-line';
2 |
3 | const cache: Record = {};
4 |
5 | type Output = {
6 | width: number;
7 | height: number;
8 | };
9 |
10 | const measureText = (text: string): Output => {
11 | if (text.length === 0) {
12 | return {
13 | width: 0,
14 | height: 0,
15 | };
16 | }
17 |
18 | const cachedDimensions = cache[text];
19 |
20 | if (cachedDimensions) {
21 | return cachedDimensions;
22 | }
23 |
24 | const width = widestLine(text);
25 | const height = text.split('\n').length;
26 | cache[text] = {width, height};
27 |
28 | return {width, height};
29 | };
30 |
31 | export default measureText;
32 |
--------------------------------------------------------------------------------
/src/output.ts:
--------------------------------------------------------------------------------
1 | import sliceAnsi from 'slice-ansi';
2 | import stringWidth from 'string-width';
3 | import widestLine from 'widest-line';
4 | import {
5 | type StyledChar,
6 | styledCharsFromTokens,
7 | styledCharsToString,
8 | tokenize,
9 | } from '@alcalzone/ansi-tokenize';
10 | import {type OutputTransformer} from './render-node-to-output.js';
11 |
12 | /**
13 | * "Virtual" output class
14 | *
15 | * Handles the positioning and saving of the output of each node in the tree.
16 | * Also responsible for applying transformations to each character of the output.
17 | *
18 | * Used to generate the final output of all nodes before writing it to actual output stream (e.g. stdout)
19 | */
20 |
21 | type Options = {
22 | width: number;
23 | height: number;
24 | };
25 |
26 | type Operation = WriteOperation | ClipOperation | UnclipOperation;
27 |
28 | type WriteOperation = {
29 | type: 'write';
30 | x: number;
31 | y: number;
32 | text: string;
33 | transformers: OutputTransformer[];
34 | };
35 |
36 | type ClipOperation = {
37 | type: 'clip';
38 | clip: Clip;
39 | };
40 |
41 | type Clip = {
42 | x1: number | undefined;
43 | x2: number | undefined;
44 | y1: number | undefined;
45 | y2: number | undefined;
46 | };
47 |
48 | type UnclipOperation = {
49 | type: 'unclip';
50 | };
51 |
52 | export default class Output {
53 | width: number;
54 | height: number;
55 |
56 | private readonly operations: Operation[] = [];
57 |
58 | constructor(options: Options) {
59 | const {width, height} = options;
60 |
61 | this.width = width;
62 | this.height = height;
63 | }
64 |
65 | write(
66 | x: number,
67 | y: number,
68 | text: string,
69 | options: {transformers: OutputTransformer[]},
70 | ): void {
71 | const {transformers} = options;
72 |
73 | if (!text) {
74 | return;
75 | }
76 |
77 | this.operations.push({
78 | type: 'write',
79 | x,
80 | y,
81 | text,
82 | transformers,
83 | });
84 | }
85 |
86 | clip(clip: Clip) {
87 | this.operations.push({
88 | type: 'clip',
89 | clip,
90 | });
91 | }
92 |
93 | unclip() {
94 | this.operations.push({
95 | type: 'unclip',
96 | });
97 | }
98 |
99 | get(): {output: string; height: number} {
100 | // Initialize output array with a specific set of rows, so that margin/padding at the bottom is preserved
101 | const output: StyledChar[][] = [];
102 |
103 | for (let y = 0; y < this.height; y++) {
104 | const row: StyledChar[] = [];
105 |
106 | for (let x = 0; x < this.width; x++) {
107 | row.push({
108 | type: 'char',
109 | value: ' ',
110 | fullWidth: false,
111 | styles: [],
112 | });
113 | }
114 |
115 | output.push(row);
116 | }
117 |
118 | const clips: Clip[] = [];
119 |
120 | for (const operation of this.operations) {
121 | if (operation.type === 'clip') {
122 | clips.push(operation.clip);
123 | }
124 |
125 | if (operation.type === 'unclip') {
126 | clips.pop();
127 | }
128 |
129 | if (operation.type === 'write') {
130 | const {text, transformers} = operation;
131 | let {x, y} = operation;
132 | let lines = text.split('\n');
133 |
134 | const clip = clips.at(-1);
135 |
136 | if (clip) {
137 | const clipHorizontally =
138 | typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number';
139 |
140 | const clipVertically =
141 | typeof clip?.y1 === 'number' && typeof clip?.y2 === 'number';
142 |
143 | // If text is positioned outside of clipping area altogether,
144 | // skip to the next operation to avoid unnecessary calculations
145 | if (clipHorizontally) {
146 | const width = widestLine(text);
147 |
148 | if (x + width < clip.x1! || x > clip.x2!) {
149 | continue;
150 | }
151 | }
152 |
153 | if (clipVertically) {
154 | const height = lines.length;
155 |
156 | if (y + height < clip.y1! || y > clip.y2!) {
157 | continue;
158 | }
159 | }
160 |
161 | if (clipHorizontally) {
162 | lines = lines.map(line => {
163 | const from = x < clip.x1! ? clip.x1! - x : 0;
164 | const width = stringWidth(line);
165 | const to = x + width > clip.x2! ? clip.x2! - x : width;
166 |
167 | return sliceAnsi(line, from, to);
168 | });
169 |
170 | if (x < clip.x1!) {
171 | x = clip.x1!;
172 | }
173 | }
174 |
175 | if (clipVertically) {
176 | const from = y < clip.y1! ? clip.y1! - y : 0;
177 | const height = lines.length;
178 | const to = y + height > clip.y2! ? clip.y2! - y : height;
179 |
180 | lines = lines.slice(from, to);
181 |
182 | if (y < clip.y1!) {
183 | y = clip.y1!;
184 | }
185 | }
186 | }
187 |
188 | let offsetY = 0;
189 |
190 | for (let [index, line] of lines.entries()) {
191 | const currentLine = output[y + offsetY];
192 |
193 | // Line can be missing if `text` is taller than height of pre-initialized `this.output`
194 | if (!currentLine) {
195 | continue;
196 | }
197 |
198 | for (const transformer of transformers) {
199 | line = transformer(line, index);
200 | }
201 |
202 | const characters = styledCharsFromTokens(tokenize(line));
203 | let offsetX = x;
204 |
205 | for (const character of characters) {
206 | currentLine[offsetX] = character;
207 |
208 | // Some characters take up more than one column. In that case, the following
209 | // pixels need to be cleared to avoid printing extra characters
210 | const isWideCharacter =
211 | character.fullWidth || character.value.length > 1;
212 |
213 | if (isWideCharacter) {
214 | currentLine[offsetX + 1] = {
215 | type: 'char',
216 | value: '',
217 | fullWidth: false,
218 | styles: character.styles,
219 | };
220 | }
221 |
222 | offsetX += isWideCharacter ? 2 : 1;
223 | }
224 |
225 | offsetY++;
226 | }
227 | }
228 | }
229 |
230 | const generatedOutput = output
231 | .map(line => {
232 | // See https://github.com/vadimdemedes/ink/pull/564#issuecomment-1637022742
233 | const lineWithoutEmptyItems = line.filter(item => item !== undefined);
234 |
235 | return styledCharsToString(lineWithoutEmptyItems).trimEnd();
236 | })
237 | .join('\n');
238 |
239 | return {
240 | output: generatedOutput,
241 | height: output.length,
242 | };
243 | }
244 | }
245 |
--------------------------------------------------------------------------------
/src/parse-keypress.ts:
--------------------------------------------------------------------------------
1 | // Copied from https://github.com/enquirer/enquirer/blob/36785f3399a41cd61e9d28d1eb9c2fcd73d69b4c/lib/keypress.js
2 | import {Buffer} from 'node:buffer';
3 |
4 | const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/;
5 |
6 | const fnKeyRe =
7 | /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/;
8 |
9 | const keyName: Record = {
10 | /* xterm/gnome ESC O letter */
11 | OP: 'f1',
12 | OQ: 'f2',
13 | OR: 'f3',
14 | OS: 'f4',
15 | /* xterm/rxvt ESC [ number ~ */
16 | '[11~': 'f1',
17 | '[12~': 'f2',
18 | '[13~': 'f3',
19 | '[14~': 'f4',
20 | /* from Cygwin and used in libuv */
21 | '[[A': 'f1',
22 | '[[B': 'f2',
23 | '[[C': 'f3',
24 | '[[D': 'f4',
25 | '[[E': 'f5',
26 | /* common */
27 | '[15~': 'f5',
28 | '[17~': 'f6',
29 | '[18~': 'f7',
30 | '[19~': 'f8',
31 | '[20~': 'f9',
32 | '[21~': 'f10',
33 | '[23~': 'f11',
34 | '[24~': 'f12',
35 | /* xterm ESC [ letter */
36 | '[A': 'up',
37 | '[B': 'down',
38 | '[C': 'right',
39 | '[D': 'left',
40 | '[E': 'clear',
41 | '[F': 'end',
42 | '[H': 'home',
43 | /* xterm/gnome ESC O letter */
44 | OA: 'up',
45 | OB: 'down',
46 | OC: 'right',
47 | OD: 'left',
48 | OE: 'clear',
49 | OF: 'end',
50 | OH: 'home',
51 | /* xterm/rxvt ESC [ number ~ */
52 | '[1~': 'home',
53 | '[2~': 'insert',
54 | '[3~': 'delete',
55 | '[4~': 'end',
56 | '[5~': 'pageup',
57 | '[6~': 'pagedown',
58 | /* putty */
59 | '[[5~': 'pageup',
60 | '[[6~': 'pagedown',
61 | /* rxvt */
62 | '[7~': 'home',
63 | '[8~': 'end',
64 | /* rxvt keys with modifiers */
65 | '[a': 'up',
66 | '[b': 'down',
67 | '[c': 'right',
68 | '[d': 'left',
69 | '[e': 'clear',
70 |
71 | '[2$': 'insert',
72 | '[3$': 'delete',
73 | '[5$': 'pageup',
74 | '[6$': 'pagedown',
75 | '[7$': 'home',
76 | '[8$': 'end',
77 |
78 | Oa: 'up',
79 | Ob: 'down',
80 | Oc: 'right',
81 | Od: 'left',
82 | Oe: 'clear',
83 |
84 | '[2^': 'insert',
85 | '[3^': 'delete',
86 | '[5^': 'pageup',
87 | '[6^': 'pagedown',
88 | '[7^': 'home',
89 | '[8^': 'end',
90 | /* misc. */
91 | '[Z': 'tab',
92 | };
93 |
94 | export const nonAlphanumericKeys = [...Object.values(keyName), 'backspace'];
95 |
96 | const isShiftKey = (code: string) => {
97 | return [
98 | '[a',
99 | '[b',
100 | '[c',
101 | '[d',
102 | '[e',
103 | '[2$',
104 | '[3$',
105 | '[5$',
106 | '[6$',
107 | '[7$',
108 | '[8$',
109 | '[Z',
110 | ].includes(code);
111 | };
112 |
113 | const isCtrlKey = (code: string) => {
114 | return [
115 | 'Oa',
116 | 'Ob',
117 | 'Oc',
118 | 'Od',
119 | 'Oe',
120 | '[2^',
121 | '[3^',
122 | '[5^',
123 | '[6^',
124 | '[7^',
125 | '[8^',
126 | ].includes(code);
127 | };
128 |
129 | type ParsedKey = {
130 | name: string;
131 | ctrl: boolean;
132 | meta: boolean;
133 | shift: boolean;
134 | option: boolean;
135 | sequence: string;
136 | raw: string | undefined;
137 | code?: string;
138 | };
139 |
140 | const parseKeypress = (s: Buffer | string = ''): ParsedKey => {
141 | let parts;
142 |
143 | if (Buffer.isBuffer(s)) {
144 | if (s[0]! > 127 && s[1] === undefined) {
145 | (s[0] as unknown as number) -= 128;
146 | s = '\x1b' + String(s);
147 | } else {
148 | s = String(s);
149 | }
150 | } else if (s !== undefined && typeof s !== 'string') {
151 | s = String(s);
152 | } else if (!s) {
153 | s = '';
154 | }
155 |
156 | const key: ParsedKey = {
157 | name: '',
158 | ctrl: false,
159 | meta: false,
160 | shift: false,
161 | option: false,
162 | sequence: s,
163 | raw: s,
164 | };
165 |
166 | key.sequence = key.sequence || s || key.name;
167 |
168 | if (s === '\r') {
169 | // carriage return
170 | key.raw = undefined;
171 | key.name = 'return';
172 | } else if (s === '\n') {
173 | // enter, should have been called linefeed
174 | key.name = 'enter';
175 | } else if (s === '\t') {
176 | // tab
177 | key.name = 'tab';
178 | } else if (s === '\b' || s === '\x1b\b') {
179 | // backspace or ctrl+h
180 | key.name = 'backspace';
181 | key.meta = s.charAt(0) === '\x1b';
182 | } else if (s === '\x7f' || s === '\x1b\x7f') {
183 | // TODO(vadimdemedes): `enquirer` detects delete key as backspace, but I had to split them up to avoid breaking changes in Ink. Merge them back together in the next major version.
184 | // delete
185 | key.name = 'delete';
186 | key.meta = s.charAt(0) === '\x1b';
187 | } else if (s === '\x1b' || s === '\x1b\x1b') {
188 | // escape key
189 | key.name = 'escape';
190 | key.meta = s.length === 2;
191 | } else if (s === ' ' || s === '\x1b ') {
192 | key.name = 'space';
193 | key.meta = s.length === 2;
194 | } else if (s.length === 1 && s <= '\x1a') {
195 | // ctrl+letter
196 | key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1);
197 | key.ctrl = true;
198 | } else if (s.length === 1 && s >= '0' && s <= '9') {
199 | // number
200 | key.name = 'number';
201 | } else if (s.length === 1 && s >= 'a' && s <= 'z') {
202 | // lowercase letter
203 | key.name = s;
204 | } else if (s.length === 1 && s >= 'A' && s <= 'Z') {
205 | // shift+letter
206 | key.name = s.toLowerCase();
207 | key.shift = true;
208 | } else if ((parts = metaKeyCodeRe.exec(s))) {
209 | // meta+character key
210 | key.meta = true;
211 | key.shift = /^[A-Z]$/.test(parts[1]!);
212 | } else if ((parts = fnKeyRe.exec(s))) {
213 | const segs = [...s];
214 |
215 | if (segs[0] === '\u001b' && segs[1] === '\u001b') {
216 | key.option = true;
217 | }
218 |
219 | // ansi escape sequence
220 | // reassemble the key code leaving out leading \x1b's,
221 | // the modifier key bitflag and any meaningless "1;" sequence
222 | const code = [parts[1], parts[2], parts[4], parts[6]]
223 | .filter(Boolean)
224 | .join('');
225 |
226 | const modifier = ((parts[3] || parts[5] || 1) as number) - 1;
227 |
228 | // Parse the key modifier
229 | key.ctrl = !!(modifier & 4);
230 | key.meta = !!(modifier & 10);
231 | key.shift = !!(modifier & 1);
232 | key.code = code;
233 |
234 | key.name = keyName[code]!;
235 | key.shift = isShiftKey(code) || key.shift;
236 | key.ctrl = isCtrlKey(code) || key.ctrl;
237 | }
238 |
239 | return key;
240 | };
241 |
242 | export default parseKeypress;
243 |
--------------------------------------------------------------------------------
/src/reconciler.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import createReconciler, {type ReactContext} from 'react-reconciler';
3 | import {
4 | DefaultEventPriority,
5 | NoEventPriority,
6 | } from 'react-reconciler/constants.js';
7 | import Yoga, {type Node as YogaNode} from 'yoga-layout';
8 | import {createContext} from 'react';
9 | import {
10 | createTextNode,
11 | appendChildNode,
12 | insertBeforeNode,
13 | removeChildNode,
14 | setStyle,
15 | setTextNodeValue,
16 | createNode,
17 | setAttribute,
18 | type DOMNodeAttribute,
19 | type TextNode,
20 | type ElementNames,
21 | type DOMElement,
22 | } from './dom.js';
23 | import applyStyles, {type Styles} from './styles.js';
24 | import {type OutputTransformer} from './render-node-to-output.js';
25 |
26 | // We need to conditionally perform devtools connection to avoid
27 | // accidentally breaking other third-party code.
28 | // See https://github.com/vadimdemedes/ink/issues/384
29 | if (process.env['DEV'] === 'true') {
30 | try {
31 | await import('./devtools.js');
32 | } catch (error: any) {
33 | if (error.code === 'ERR_MODULE_NOT_FOUND') {
34 | console.warn(
35 | `
36 | The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`,
37 | but this failed as it was not installed. Debugging with React Devtools requires it.
38 |
39 | To install use this command:
40 |
41 | $ npm install --save-dev react-devtools-core
42 | `.trim() + '\n',
43 | );
44 | } else {
45 | // eslint-disable-next-line @typescript-eslint/only-throw-error
46 | throw error;
47 | }
48 | }
49 | }
50 |
51 | type AnyObject = Record;
52 |
53 | const diff = (before: AnyObject, after: AnyObject): AnyObject | undefined => {
54 | if (before === after) {
55 | return;
56 | }
57 |
58 | if (!before) {
59 | return after;
60 | }
61 |
62 | const changed: AnyObject = {};
63 | let isChanged = false;
64 |
65 | for (const key of Object.keys(before)) {
66 | const isDeleted = after ? !Object.hasOwn(after, key) : true;
67 |
68 | if (isDeleted) {
69 | changed[key] = undefined;
70 | isChanged = true;
71 | }
72 | }
73 |
74 | if (after) {
75 | for (const key of Object.keys(after)) {
76 | if (after[key] !== before[key]) {
77 | changed[key] = after[key];
78 | isChanged = true;
79 | }
80 | }
81 | }
82 |
83 | return isChanged ? changed : undefined;
84 | };
85 |
86 | const cleanupYogaNode = (node?: YogaNode): void => {
87 | node?.unsetMeasureFunc();
88 | node?.freeRecursive();
89 | };
90 |
91 | type Props = Record;
92 |
93 | type HostContext = {
94 | isInsideText: boolean;
95 | };
96 |
97 | let currentUpdatePriority = NoEventPriority;
98 |
99 | export default createReconciler<
100 | ElementNames,
101 | Props,
102 | DOMElement,
103 | DOMElement,
104 | TextNode,
105 | DOMElement,
106 | unknown,
107 | unknown,
108 | unknown,
109 | HostContext,
110 | unknown,
111 | unknown,
112 | unknown,
113 | unknown
114 | >({
115 | getRootHostContext: () => ({
116 | isInsideText: false,
117 | }),
118 | prepareForCommit: () => null,
119 | preparePortalMount: () => null,
120 | clearContainer: () => false,
121 | resetAfterCommit(rootNode) {
122 | if (typeof rootNode.onComputeLayout === 'function') {
123 | rootNode.onComputeLayout();
124 | }
125 |
126 | // Since renders are throttled at the instance level and component children
127 | // are rendered only once and then get deleted, we need an escape hatch to
128 | // trigger an immediate render to ensure children are written to output before they get erased
129 | if (rootNode.isStaticDirty) {
130 | rootNode.isStaticDirty = false;
131 | if (typeof rootNode.onImmediateRender === 'function') {
132 | rootNode.onImmediateRender();
133 | }
134 |
135 | return;
136 | }
137 |
138 | if (typeof rootNode.onRender === 'function') {
139 | rootNode.onRender();
140 | }
141 | },
142 | getChildHostContext(parentHostContext, type) {
143 | const previousIsInsideText = parentHostContext.isInsideText;
144 | const isInsideText = type === 'ink-text' || type === 'ink-virtual-text';
145 |
146 | if (previousIsInsideText === isInsideText) {
147 | return parentHostContext;
148 | }
149 |
150 | return {isInsideText};
151 | },
152 | shouldSetTextContent: () => false,
153 | createInstance(originalType, newProps, rootNode, hostContext) {
154 | if (hostContext.isInsideText && originalType === 'ink-box') {
155 | throw new Error(` can’t be nested inside component`);
156 | }
157 |
158 | const type =
159 | originalType === 'ink-text' && hostContext.isInsideText
160 | ? 'ink-virtual-text'
161 | : originalType;
162 |
163 | const node = createNode(type);
164 |
165 | for (const [key, value] of Object.entries(newProps)) {
166 | if (key === 'children') {
167 | continue;
168 | }
169 |
170 | if (key === 'style') {
171 | setStyle(node, value as Styles);
172 |
173 | if (node.yogaNode) {
174 | applyStyles(node.yogaNode, value as Styles);
175 | }
176 |
177 | continue;
178 | }
179 |
180 | if (key === 'internal_transform') {
181 | node.internal_transform = value as OutputTransformer;
182 | continue;
183 | }
184 |
185 | if (key === 'internal_static') {
186 | node.internal_static = true;
187 | rootNode.isStaticDirty = true;
188 |
189 | // Save reference to node to skip traversal of entire
190 | // node tree to find it
191 | rootNode.staticNode = node;
192 | continue;
193 | }
194 |
195 | setAttribute(node, key, value as DOMNodeAttribute);
196 | }
197 |
198 | return node;
199 | },
200 | createTextInstance(text, _root, hostContext) {
201 | if (!hostContext.isInsideText) {
202 | throw new Error(
203 | `Text string "${text}" must be rendered inside component`,
204 | );
205 | }
206 |
207 | return createTextNode(text);
208 | },
209 | resetTextContent() {},
210 | hideTextInstance(node) {
211 | setTextNodeValue(node, '');
212 | },
213 | unhideTextInstance(node, text) {
214 | setTextNodeValue(node, text);
215 | },
216 | getPublicInstance: instance => instance,
217 | hideInstance(node) {
218 | node.yogaNode?.setDisplay(Yoga.DISPLAY_NONE);
219 | },
220 | unhideInstance(node) {
221 | node.yogaNode?.setDisplay(Yoga.DISPLAY_FLEX);
222 | },
223 | appendInitialChild: appendChildNode,
224 | appendChild: appendChildNode,
225 | insertBefore: insertBeforeNode,
226 | finalizeInitialChildren() {
227 | return false;
228 | },
229 | isPrimaryRenderer: true,
230 | supportsMutation: true,
231 | supportsPersistence: false,
232 | supportsHydration: false,
233 | scheduleTimeout: setTimeout,
234 | cancelTimeout: clearTimeout,
235 | noTimeout: -1,
236 | beforeActiveInstanceBlur() {},
237 | afterActiveInstanceBlur() {},
238 | detachDeletedInstance() {},
239 | getInstanceFromNode: () => null,
240 | prepareScopeUpdate() {},
241 | getInstanceFromScope: () => null,
242 | appendChildToContainer: appendChildNode,
243 | insertInContainerBefore: insertBeforeNode,
244 | removeChildFromContainer(node, removeNode) {
245 | removeChildNode(node, removeNode);
246 | cleanupYogaNode(removeNode.yogaNode);
247 | },
248 | commitUpdate(node, _type, oldProps, newProps, _root) {
249 | const props = diff(oldProps, newProps);
250 |
251 | const style = diff(
252 | oldProps['style'] as Styles,
253 | newProps['style'] as Styles,
254 | );
255 |
256 | if (!props && !style) {
257 | return;
258 | }
259 |
260 | if (props) {
261 | for (const [key, value] of Object.entries(props)) {
262 | if (key === 'style') {
263 | setStyle(node, value as Styles);
264 | continue;
265 | }
266 |
267 | if (key === 'internal_transform') {
268 | node.internal_transform = value as OutputTransformer;
269 | continue;
270 | }
271 |
272 | if (key === 'internal_static') {
273 | node.internal_static = true;
274 | continue;
275 | }
276 |
277 | setAttribute(node, key, value as DOMNodeAttribute);
278 | }
279 | }
280 |
281 | if (style && node.yogaNode) {
282 | applyStyles(node.yogaNode, style);
283 | }
284 | },
285 | commitTextUpdate(node, _oldText, newText) {
286 | setTextNodeValue(node, newText);
287 | },
288 | removeChild(node, removeNode) {
289 | removeChildNode(node, removeNode);
290 | cleanupYogaNode(removeNode.yogaNode);
291 | },
292 | setCurrentUpdatePriority(newPriority: number) {
293 | currentUpdatePriority = newPriority;
294 | },
295 | getCurrentUpdatePriority: () => currentUpdatePriority,
296 | resolveUpdatePriority() {
297 | if (currentUpdatePriority !== NoEventPriority) {
298 | return currentUpdatePriority;
299 | }
300 |
301 | return DefaultEventPriority;
302 | },
303 | maySuspendCommit() {
304 | return false;
305 | },
306 | // eslint-disable-next-line @typescript-eslint/naming-convention
307 | NotPendingTransition: undefined,
308 | // eslint-disable-next-line @typescript-eslint/naming-convention
309 | HostTransitionContext: createContext(
310 | null,
311 | ) as unknown as ReactContext,
312 | resetFormInstance() {},
313 | requestPostPaintCallback() {},
314 | shouldAttemptEagerTransition() {
315 | return false;
316 | },
317 | trackSchedulerEvent() {},
318 | resolveEventType() {
319 | return null;
320 | },
321 | resolveEventTimeStamp() {
322 | return -1.1;
323 | },
324 | preloadInstance() {
325 | return true;
326 | },
327 | startSuspendingCommit() {},
328 | suspendInstance() {},
329 | waitForCommitToBeReady() {
330 | return null;
331 | },
332 | });
333 |
--------------------------------------------------------------------------------
/src/render-border.ts:
--------------------------------------------------------------------------------
1 | import cliBoxes from 'cli-boxes';
2 | import chalk from 'chalk';
3 | import colorize from './colorize.js';
4 | import {type DOMNode} from './dom.js';
5 | import type Output from './output.js';
6 |
7 | const renderBorder = (
8 | x: number,
9 | y: number,
10 | node: DOMNode,
11 | output: Output,
12 | ): void => {
13 | if (node.style.borderStyle) {
14 | const width = node.yogaNode!.getComputedWidth();
15 | const height = node.yogaNode!.getComputedHeight();
16 | const box =
17 | typeof node.style.borderStyle === 'string'
18 | ? cliBoxes[node.style.borderStyle]
19 | : node.style.borderStyle;
20 |
21 | const topBorderColor = node.style.borderTopColor ?? node.style.borderColor;
22 | const bottomBorderColor =
23 | node.style.borderBottomColor ?? node.style.borderColor;
24 | const leftBorderColor =
25 | node.style.borderLeftColor ?? node.style.borderColor;
26 | const rightBorderColor =
27 | node.style.borderRightColor ?? node.style.borderColor;
28 |
29 | const dimTopBorderColor =
30 | node.style.borderTopDimColor ?? node.style.borderDimColor;
31 |
32 | const dimBottomBorderColor =
33 | node.style.borderBottomDimColor ?? node.style.borderDimColor;
34 |
35 | const dimLeftBorderColor =
36 | node.style.borderLeftDimColor ?? node.style.borderDimColor;
37 |
38 | const dimRightBorderColor =
39 | node.style.borderRightDimColor ?? node.style.borderDimColor;
40 |
41 | const showTopBorder = node.style.borderTop !== false;
42 | const showBottomBorder = node.style.borderBottom !== false;
43 | const showLeftBorder = node.style.borderLeft !== false;
44 | const showRightBorder = node.style.borderRight !== false;
45 |
46 | const contentWidth =
47 | width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0);
48 |
49 | let topBorder = showTopBorder
50 | ? colorize(
51 | (showLeftBorder ? box.topLeft : '') +
52 | box.top.repeat(contentWidth) +
53 | (showRightBorder ? box.topRight : ''),
54 | topBorderColor,
55 | 'foreground',
56 | )
57 | : undefined;
58 |
59 | if (showTopBorder && dimTopBorderColor) {
60 | topBorder = chalk.dim(topBorder);
61 | }
62 |
63 | let verticalBorderHeight = height;
64 |
65 | if (showTopBorder) {
66 | verticalBorderHeight -= 1;
67 | }
68 |
69 | if (showBottomBorder) {
70 | verticalBorderHeight -= 1;
71 | }
72 |
73 | let leftBorder = (
74 | colorize(box.left, leftBorderColor, 'foreground') + '\n'
75 | ).repeat(verticalBorderHeight);
76 |
77 | if (dimLeftBorderColor) {
78 | leftBorder = chalk.dim(leftBorder);
79 | }
80 |
81 | let rightBorder = (
82 | colorize(box.right, rightBorderColor, 'foreground') + '\n'
83 | ).repeat(verticalBorderHeight);
84 |
85 | if (dimRightBorderColor) {
86 | rightBorder = chalk.dim(rightBorder);
87 | }
88 |
89 | let bottomBorder = showBottomBorder
90 | ? colorize(
91 | (showLeftBorder ? box.bottomLeft : '') +
92 | box.bottom.repeat(contentWidth) +
93 | (showRightBorder ? box.bottomRight : ''),
94 | bottomBorderColor,
95 | 'foreground',
96 | )
97 | : undefined;
98 |
99 | if (showBottomBorder && dimBottomBorderColor) {
100 | bottomBorder = chalk.dim(bottomBorder);
101 | }
102 |
103 | const offsetY = showTopBorder ? 1 : 0;
104 |
105 | if (topBorder) {
106 | output.write(x, y, topBorder, {transformers: []});
107 | }
108 |
109 | if (showLeftBorder) {
110 | output.write(x, y + offsetY, leftBorder, {transformers: []});
111 | }
112 |
113 | if (showRightBorder) {
114 | output.write(x + width - 1, y + offsetY, rightBorder, {
115 | transformers: [],
116 | });
117 | }
118 |
119 | if (bottomBorder) {
120 | output.write(x, y + height - 1, bottomBorder, {transformers: []});
121 | }
122 | }
123 | };
124 |
125 | export default renderBorder;
126 |
--------------------------------------------------------------------------------
/src/render-node-to-output.ts:
--------------------------------------------------------------------------------
1 | import widestLine from 'widest-line';
2 | import indentString from 'indent-string';
3 | import Yoga from 'yoga-layout';
4 | import wrapText from './wrap-text.js';
5 | import getMaxWidth from './get-max-width.js';
6 | import squashTextNodes from './squash-text-nodes.js';
7 | import renderBorder from './render-border.js';
8 | import {type DOMElement} from './dom.js';
9 | import type Output from './output.js';
10 |
11 | // If parent container is ``, text nodes will be treated as separate nodes in
12 | // the tree and will have their own coordinates in the layout.
13 | // To ensure text nodes are aligned correctly, take X and Y of the first text node
14 | // and use it as offset for the rest of the nodes
15 | // Only first node is taken into account, because other text nodes can't have margin or padding,
16 | // so their coordinates will be relative to the first node anyway
17 | const applyPaddingToText = (node: DOMElement, text: string): string => {
18 | const yogaNode = node.childNodes[0]?.yogaNode;
19 |
20 | if (yogaNode) {
21 | const offsetX = yogaNode.getComputedLeft();
22 | const offsetY = yogaNode.getComputedTop();
23 | text = '\n'.repeat(offsetY) + indentString(text, offsetX);
24 | }
25 |
26 | return text;
27 | };
28 |
29 | export type OutputTransformer = (s: string, index: number) => string;
30 |
31 | // After nodes are laid out, render each to output object, which later gets rendered to terminal
32 | const renderNodeToOutput = (
33 | node: DOMElement,
34 | output: Output,
35 | options: {
36 | offsetX?: number;
37 | offsetY?: number;
38 | transformers?: OutputTransformer[];
39 | skipStaticElements: boolean;
40 | },
41 | ) => {
42 | const {
43 | offsetX = 0,
44 | offsetY = 0,
45 | transformers = [],
46 | skipStaticElements,
47 | } = options;
48 |
49 | if (skipStaticElements && node.internal_static) {
50 | return;
51 | }
52 |
53 | const {yogaNode} = node;
54 |
55 | if (yogaNode) {
56 | if (yogaNode.getDisplay() === Yoga.DISPLAY_NONE) {
57 | return;
58 | }
59 |
60 | // Left and top positions in Yoga are relative to their parent node
61 | const x = offsetX + yogaNode.getComputedLeft();
62 | const y = offsetY + yogaNode.getComputedTop();
63 |
64 | // Transformers are functions that transform final text output of each component
65 | // See Output class for logic that applies transformers
66 | let newTransformers = transformers;
67 |
68 | if (typeof node.internal_transform === 'function') {
69 | newTransformers = [node.internal_transform, ...transformers];
70 | }
71 |
72 | if (node.nodeName === 'ink-text') {
73 | let text = squashTextNodes(node);
74 |
75 | if (text.length > 0) {
76 | const currentWidth = widestLine(text);
77 | const maxWidth = getMaxWidth(yogaNode);
78 |
79 | if (currentWidth > maxWidth) {
80 | const textWrap = node.style.textWrap ?? 'wrap';
81 | text = wrapText(text, maxWidth, textWrap);
82 | }
83 |
84 | text = applyPaddingToText(node, text);
85 |
86 | output.write(x, y, text, {transformers: newTransformers});
87 | }
88 |
89 | return;
90 | }
91 |
92 | let clipped = false;
93 |
94 | if (node.nodeName === 'ink-box') {
95 | renderBorder(x, y, node, output);
96 |
97 | const clipHorizontally =
98 | node.style.overflowX === 'hidden' || node.style.overflow === 'hidden';
99 | const clipVertically =
100 | node.style.overflowY === 'hidden' || node.style.overflow === 'hidden';
101 |
102 | if (clipHorizontally || clipVertically) {
103 | const x1 = clipHorizontally
104 | ? x + yogaNode.getComputedBorder(Yoga.EDGE_LEFT)
105 | : undefined;
106 |
107 | const x2 = clipHorizontally
108 | ? x +
109 | yogaNode.getComputedWidth() -
110 | yogaNode.getComputedBorder(Yoga.EDGE_RIGHT)
111 | : undefined;
112 |
113 | const y1 = clipVertically
114 | ? y + yogaNode.getComputedBorder(Yoga.EDGE_TOP)
115 | : undefined;
116 |
117 | const y2 = clipVertically
118 | ? y +
119 | yogaNode.getComputedHeight() -
120 | yogaNode.getComputedBorder(Yoga.EDGE_BOTTOM)
121 | : undefined;
122 |
123 | output.clip({x1, x2, y1, y2});
124 | clipped = true;
125 | }
126 | }
127 |
128 | if (node.nodeName === 'ink-root' || node.nodeName === 'ink-box') {
129 | for (const childNode of node.childNodes) {
130 | renderNodeToOutput(childNode as DOMElement, output, {
131 | offsetX: x,
132 | offsetY: y,
133 | transformers: newTransformers,
134 | skipStaticElements,
135 | });
136 | }
137 |
138 | if (clipped) {
139 | output.unclip();
140 | }
141 | }
142 | }
143 | };
144 |
145 | export default renderNodeToOutput;
146 |
--------------------------------------------------------------------------------
/src/render.ts:
--------------------------------------------------------------------------------
1 | import {Stream} from 'node:stream';
2 | import process from 'node:process';
3 | import type {ReactNode} from 'react';
4 | import Ink, {type Options as InkOptions} from './ink.js';
5 | import instances from './instances.js';
6 |
7 | export type RenderOptions = {
8 | /**
9 | * Output stream where app will be rendered.
10 | *
11 | * @default process.stdout
12 | */
13 | stdout?: NodeJS.WriteStream;
14 | /**
15 | * Input stream where app will listen for input.
16 | *
17 | * @default process.stdin
18 | */
19 | stdin?: NodeJS.ReadStream;
20 | /**
21 | * Error stream.
22 | * @default process.stderr
23 | */
24 | stderr?: NodeJS.WriteStream;
25 | /**
26 | * If true, each update will be rendered as a separate output, without replacing the previous one.
27 | *
28 | * @default false
29 | */
30 | debug?: boolean;
31 | /**
32 | * Configure whether Ink should listen to Ctrl+C keyboard input and exit the app. This is needed in case `process.stdin` is in raw mode, because then Ctrl+C is ignored by default and process is expected to handle it manually.
33 | *
34 | * @default true
35 | */
36 | exitOnCtrlC?: boolean;
37 |
38 | /**
39 | * Patch console methods to ensure console output doesn't mix with Ink output.
40 | *
41 | * @default true
42 | */
43 | patchConsole?: boolean;
44 | };
45 |
46 | export type Instance = {
47 | /**
48 | * Replace previous root node with a new one or update props of the current root node.
49 | */
50 | rerender: Ink['render'];
51 | /**
52 | * Manually unmount the whole Ink app.
53 | */
54 | unmount: Ink['unmount'];
55 | /**
56 | * Returns a promise, which resolves when app is unmounted.
57 | */
58 | waitUntilExit: Ink['waitUntilExit'];
59 | cleanup: () => void;
60 |
61 | /**
62 | * Clear output.
63 | */
64 | clear: () => void;
65 | };
66 |
67 | /**
68 | * Mount a component and render the output.
69 | */
70 | const render = (
71 | node: ReactNode,
72 | options?: NodeJS.WriteStream | RenderOptions,
73 | ): Instance => {
74 | const inkOptions: InkOptions = {
75 | stdout: process.stdout,
76 | stdin: process.stdin,
77 | stderr: process.stderr,
78 | debug: false,
79 | exitOnCtrlC: true,
80 | patchConsole: true,
81 | ...getOptions(options),
82 | };
83 |
84 | const instance: Ink = getInstance(
85 | inkOptions.stdout,
86 | () => new Ink(inkOptions),
87 | );
88 |
89 | instance.render(node);
90 |
91 | return {
92 | rerender: instance.render,
93 | unmount() {
94 | instance.unmount();
95 | },
96 | waitUntilExit: instance.waitUntilExit,
97 | cleanup: () => instances.delete(inkOptions.stdout),
98 | clear: instance.clear,
99 | };
100 | };
101 |
102 | export default render;
103 |
104 | const getOptions = (
105 | stdout: NodeJS.WriteStream | RenderOptions | undefined = {},
106 | ): RenderOptions => {
107 | if (stdout instanceof Stream) {
108 | return {
109 | stdout,
110 | stdin: process.stdin,
111 | };
112 | }
113 |
114 | return stdout;
115 | };
116 |
117 | const getInstance = (
118 | stdout: NodeJS.WriteStream,
119 | createInstance: () => Ink,
120 | ): Ink => {
121 | let instance = instances.get(stdout);
122 |
123 | if (!instance) {
124 | instance = createInstance();
125 | instances.set(stdout, instance);
126 | }
127 |
128 | return instance;
129 | };
130 |
--------------------------------------------------------------------------------
/src/renderer.ts:
--------------------------------------------------------------------------------
1 | import renderNodeToOutput from './render-node-to-output.js';
2 | import Output from './output.js';
3 | import {type DOMElement} from './dom.js';
4 |
5 | type Result = {
6 | output: string;
7 | outputHeight: number;
8 | staticOutput: string;
9 | };
10 |
11 | const renderer = (node: DOMElement): Result => {
12 | if (node.yogaNode) {
13 | const output = new Output({
14 | width: node.yogaNode.getComputedWidth(),
15 | height: node.yogaNode.getComputedHeight(),
16 | });
17 |
18 | renderNodeToOutput(node, output, {skipStaticElements: true});
19 |
20 | let staticOutput;
21 |
22 | if (node.staticNode?.yogaNode) {
23 | staticOutput = new Output({
24 | width: node.staticNode.yogaNode.getComputedWidth(),
25 | height: node.staticNode.yogaNode.getComputedHeight(),
26 | });
27 |
28 | renderNodeToOutput(node.staticNode, staticOutput, {
29 | skipStaticElements: false,
30 | });
31 | }
32 |
33 | const {output: generatedOutput, height: outputHeight} = output.get();
34 |
35 | return {
36 | output: generatedOutput,
37 | outputHeight,
38 | // Newline at the end is needed, because static output doesn't have one, so
39 | // interactive output will override last line of static output
40 | staticOutput: staticOutput ? `${staticOutput.get().output}\n` : '',
41 | };
42 | }
43 |
44 | return {
45 | output: '',
46 | outputHeight: 0,
47 | staticOutput: '',
48 | };
49 | };
50 |
51 | export default renderer;
52 |
--------------------------------------------------------------------------------
/src/squash-text-nodes.ts:
--------------------------------------------------------------------------------
1 | import {type DOMElement} from './dom.js';
2 |
3 | // Squashing text nodes allows to combine multiple text nodes into one and write
4 | // to `Output` instance only once. For example, hello{' '}world
5 | // is actually 3 text nodes, which would result 3 writes to `Output`.
6 | //
7 | // Also, this is necessary for libraries like ink-link (https://github.com/sindresorhus/ink-link),
8 | // which need to wrap all children at once, instead of wrapping 3 text nodes separately.
9 | const squashTextNodes = (node: DOMElement): string => {
10 | let text = '';
11 |
12 | for (let index = 0; index < node.childNodes.length; index++) {
13 | const childNode = node.childNodes[index];
14 |
15 | if (childNode === undefined) {
16 | continue;
17 | }
18 |
19 | let nodeText = '';
20 |
21 | if (childNode.nodeName === '#text') {
22 | nodeText = childNode.nodeValue;
23 | } else {
24 | if (
25 | childNode.nodeName === 'ink-text' ||
26 | childNode.nodeName === 'ink-virtual-text'
27 | ) {
28 | nodeText = squashTextNodes(childNode);
29 | }
30 |
31 | // Since these text nodes are being concatenated, `Output` instance won't be able to
32 | // apply children transform, so we have to do it manually here for each text node
33 | if (
34 | nodeText.length > 0 &&
35 | typeof childNode.internal_transform === 'function'
36 | ) {
37 | nodeText = childNode.internal_transform(nodeText, index);
38 | }
39 | }
40 |
41 | text += nodeText;
42 | }
43 |
44 | return text;
45 | };
46 |
47 | export default squashTextNodes;
48 |
--------------------------------------------------------------------------------
/src/wrap-text.ts:
--------------------------------------------------------------------------------
1 | import wrapAnsi from 'wrap-ansi';
2 | import cliTruncate from 'cli-truncate';
3 | import {type Styles} from './styles.js';
4 |
5 | const cache: Record = {};
6 |
7 | const wrapText = (
8 | text: string,
9 | maxWidth: number,
10 | wrapType: Styles['textWrap'],
11 | ): string => {
12 | const cacheKey = text + String(maxWidth) + String(wrapType);
13 | const cachedText = cache[cacheKey];
14 |
15 | if (cachedText) {
16 | return cachedText;
17 | }
18 |
19 | let wrappedText = text;
20 |
21 | if (wrapType === 'wrap') {
22 | wrappedText = wrapAnsi(text, maxWidth, {
23 | trim: false,
24 | hard: true,
25 | });
26 | }
27 |
28 | if (wrapType!.startsWith('truncate')) {
29 | let position: 'end' | 'middle' | 'start' = 'end';
30 |
31 | if (wrapType === 'truncate-middle') {
32 | position = 'middle';
33 | }
34 |
35 | if (wrapType === 'truncate-start') {
36 | position = 'start';
37 | }
38 |
39 | wrappedText = cliTruncate(text, maxWidth, {position});
40 | }
41 |
42 | cache[cacheKey] = wrappedText;
43 |
44 | return wrappedText;
45 | };
46 |
47 | export default wrapText;
48 |
--------------------------------------------------------------------------------
/test/display.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import {Box, Text} from '../src/index.js';
4 | import {renderToString} from './helpers/render-to-string.js';
5 |
6 | test('display flex', t => {
7 | const output = renderToString(
8 |
9 | X
10 | ,
11 | );
12 | t.is(output, 'X');
13 | });
14 |
15 | test('display none', t => {
16 | const output = renderToString(
17 |
18 |
19 | Kitty!
20 |
21 | Doggo
22 | ,
23 | );
24 |
25 | t.is(output, 'Doggo');
26 | });
27 |
--------------------------------------------------------------------------------
/test/errors.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import patchConsole from 'patch-console';
4 | import stripAnsi from 'strip-ansi';
5 | import {render} from '../src/index.js';
6 | import createStdout from './helpers/create-stdout.js';
7 |
8 | let restore = () => {};
9 |
10 | test.before(() => {
11 | restore = patchConsole(() => {});
12 | });
13 |
14 | test.after(() => {
15 | restore();
16 | });
17 |
18 | test('catch and display error', t => {
19 | const stdout = createStdout();
20 |
21 | const Test = () => {
22 | throw new Error('Oh no');
23 | };
24 |
25 | render(, {stdout});
26 |
27 | t.deepEqual(
28 | stripAnsi((stdout.write as any).lastCall.args[0] as string)
29 | .split('\n')
30 | .slice(0, 14),
31 | [
32 | '',
33 | ' ERROR Oh no',
34 | '',
35 | ' test/errors.tsx:22:9',
36 | '',
37 | ' 19: const stdout = createStdout();',
38 | ' 20:',
39 | ' 21: const Test = () => {',
40 | " 22: throw new Error('Oh no');",
41 | ' 23: };',
42 | ' 24:',
43 | ' 25: render(, {stdout});',
44 | '',
45 | ' - Test (test/errors.tsx:22:9)',
46 | ],
47 | );
48 | });
49 |
--------------------------------------------------------------------------------
/test/exit.tsx:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import * as path from 'node:path';
3 | import url from 'node:url';
4 | import {createRequire} from 'node:module';
5 | import test from 'ava';
6 | import {run} from './helpers/run.js';
7 |
8 | const require = createRequire(import.meta.url);
9 |
10 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports
11 | const {spawn} = require('node-pty') as typeof import('node-pty');
12 |
13 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
14 |
15 | test.serial('exit normally without unmount() or exit()', async t => {
16 | const output = await run('exit-normally');
17 | t.true(output.includes('exited'));
18 | });
19 |
20 | test.serial('exit on unmount()', async t => {
21 | const output = await run('exit-on-unmount');
22 | t.true(output.includes('exited'));
23 | });
24 |
25 | test.serial('exit when app finishes execution', async t => {
26 | const ps = run('exit-on-finish');
27 | await t.notThrowsAsync(ps);
28 | });
29 |
30 | test.serial('exit on exit()', async t => {
31 | const output = await run('exit-on-exit');
32 | t.true(output.includes('exited'));
33 | });
34 |
35 | test.serial('exit on exit() with error', async t => {
36 | const output = await run('exit-on-exit-with-error');
37 | t.true(output.includes('errored'));
38 | });
39 |
40 | test.serial('exit on exit() with raw mode', async t => {
41 | const output = await run('exit-raw-on-exit');
42 | t.true(output.includes('exited'));
43 | });
44 |
45 | test.serial('exit on exit() with raw mode with error', async t => {
46 | const output = await run('exit-raw-on-exit-with-error');
47 | t.true(output.includes('errored'));
48 | });
49 |
50 | test.serial('exit on unmount() with raw mode', async t => {
51 | const output = await run('exit-raw-on-unmount');
52 | t.true(output.includes('exited'));
53 | });
54 |
55 | test.serial('exit with thrown error', async t => {
56 | const output = await run('exit-with-thrown-error');
57 | t.true(output.includes('errored'));
58 | });
59 |
60 | test.serial('don’t exit while raw mode is active', async t => {
61 | await new Promise((resolve, _reject) => {
62 | const env: Record = {
63 | ...process.env,
64 | // eslint-disable-next-line @typescript-eslint/naming-convention
65 | NODE_NO_WARNINGS: '1',
66 | };
67 |
68 | const term = spawn(
69 | 'node',
70 | [
71 | '--loader=ts-node/esm',
72 | path.join(__dirname, './fixtures/exit-double-raw-mode.tsx'),
73 | ],
74 | {
75 | name: 'xterm-color',
76 | cols: 100,
77 | cwd: __dirname,
78 | env,
79 | },
80 | );
81 |
82 | let output = '';
83 |
84 | term.onData(data => {
85 | if (data === 's') {
86 | setTimeout(() => {
87 | t.false(isExited);
88 | term.write('q');
89 | }, 2000);
90 |
91 | setTimeout(() => {
92 | term.kill();
93 | t.fail();
94 | resolve();
95 | }, 5000);
96 | } else {
97 | output += data;
98 | }
99 | });
100 |
101 | let isExited = false;
102 |
103 | term.onExit(({exitCode}) => {
104 | isExited = true;
105 |
106 | if (exitCode === 0) {
107 | t.true(output.includes('exited'));
108 | t.pass();
109 | resolve();
110 | return;
111 | }
112 |
113 | t.fail();
114 | resolve();
115 | });
116 | });
117 | });
118 |
--------------------------------------------------------------------------------
/test/fixtures/ci.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Static, Text} from '../../src/index.js';
3 |
4 | type TestState = {
5 | counter: number;
6 | items: string[];
7 | };
8 |
9 | class Test extends React.Component, TestState> {
10 | timer?: NodeJS.Timeout;
11 |
12 | override state: TestState = {
13 | items: [],
14 | counter: 0,
15 | };
16 |
17 | override render() {
18 | return (
19 | <>
20 |
21 | {item => {item}}
22 |
23 |
24 | Counter: {this.state.counter}
25 | >
26 | );
27 | }
28 |
29 | override componentDidMount() {
30 | const onTimeout = () => {
31 | if (this.state.counter > 4) {
32 | return;
33 | }
34 |
35 | this.setState(prevState => ({
36 | counter: prevState.counter + 1,
37 | items: [...prevState.items, `#${prevState.counter + 1}`],
38 | }));
39 |
40 | this.timer = setTimeout(onTimeout, 100);
41 | };
42 |
43 | this.timer = setTimeout(onTimeout, 100);
44 | }
45 |
46 | override componentWillUnmount() {
47 | clearTimeout(this.timer);
48 | }
49 | }
50 |
51 | render();
52 |
--------------------------------------------------------------------------------
/test/fixtures/clear.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Box, Text, render} from '../../src/index.js';
3 |
4 | function Clear() {
5 | return (
6 |
7 | A
8 | B
9 | C
10 |
11 | );
12 | }
13 |
14 | const {clear} = render();
15 | clear();
16 |
--------------------------------------------------------------------------------
/test/fixtures/console.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect} from 'react';
2 | import {Text, render} from '../../src/index.js';
3 |
4 | function App() {
5 | useEffect(() => {
6 | const timer = setTimeout(() => {}, 1000);
7 |
8 | return () => {
9 | clearTimeout(timer);
10 | };
11 | }, []);
12 |
13 | return Hello World;
14 | }
15 |
16 | const {unmount} = render();
17 | console.log('First log');
18 | unmount();
19 | console.log('Second log');
20 |
--------------------------------------------------------------------------------
/test/fixtures/erase-with-state-change.tsx:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import React, {useEffect, useState} from 'react';
3 | import {Box, Text, render} from '../../src/index.js';
4 |
5 | function Erase() {
6 | const [show, setShow] = useState(true);
7 |
8 | useEffect(() => {
9 | const timer = setTimeout(() => {
10 | setShow(false);
11 | });
12 |
13 | return () => {
14 | clearTimeout(timer);
15 | };
16 | }, []);
17 |
18 | return (
19 |
20 | {show && (
21 | <>
22 | A
23 | B
24 | C
25 | >
26 | )}
27 |
28 | );
29 | }
30 |
31 | process.stdout.rows = Number(process.argv[2]);
32 | render();
33 |
--------------------------------------------------------------------------------
/test/fixtures/erase-with-static.tsx:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import React from 'react';
3 | import {Static, Box, Text, render} from '../../src/index.js';
4 |
5 | function EraseWithStatic() {
6 | return (
7 | <>
8 |
9 | {item => {item}}
10 |
11 |
12 |
13 | D
14 | E
15 | F
16 |
17 | >
18 | );
19 | }
20 |
21 | process.stdout.rows = Number(process.argv[3]);
22 | render();
23 |
--------------------------------------------------------------------------------
/test/fixtures/erase.tsx:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import React from 'react';
3 | import {Box, Text, render} from '../../src/index.js';
4 |
5 | function Erase() {
6 | return (
7 |
8 | A
9 | B
10 | C
11 |
12 | );
13 | }
14 |
15 | process.stdout.rows = Number(process.argv[2]);
16 | render();
17 |
--------------------------------------------------------------------------------
/test/fixtures/exit-double-raw-mode.tsx:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import React from 'react';
3 | import {Text, render, useStdin} from '../../src/index.js';
4 |
5 | class ExitDoubleRawMode extends React.Component<{
6 | setRawMode: (value: boolean) => void;
7 | }> {
8 | override render() {
9 | return Hello World;
10 | }
11 |
12 | override componentDidMount() {
13 | const {setRawMode} = this.props;
14 |
15 | setRawMode(true);
16 |
17 | setTimeout(() => {
18 | setRawMode(false);
19 | setRawMode(true);
20 |
21 | // Start the test
22 | process.stdout.write('s');
23 | }, 500);
24 | }
25 | }
26 |
27 | function Test() {
28 | const {setRawMode} = useStdin();
29 |
30 | return ;
31 | }
32 |
33 | const {unmount, waitUntilExit} = render();
34 |
35 | process.stdin.on('data', data => {
36 | if (String(data) === 'q') {
37 | unmount();
38 | }
39 | });
40 |
41 | await waitUntilExit();
42 | console.log('exited');
43 |
--------------------------------------------------------------------------------
/test/fixtures/exit-normally.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Text, render} from '../../src/index.js';
3 |
4 | const {waitUntilExit} = render(Hello World);
5 |
6 | await waitUntilExit();
7 | console.log('exited');
8 |
--------------------------------------------------------------------------------
/test/fixtures/exit-on-exit-with-error.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Text, useApp} from '../../src/index.js';
3 |
4 | class Exit extends React.Component<
5 | {onExit: (error: Error) => void},
6 | {counter: number}
7 | > {
8 | timer?: NodeJS.Timeout;
9 |
10 | override state = {
11 | counter: 0,
12 | };
13 |
14 | override render() {
15 | return Counter: {this.state.counter};
16 | }
17 |
18 | override componentDidMount() {
19 | setTimeout(() => {
20 | this.props.onExit(new Error('errored'));
21 | }, 500);
22 |
23 | this.timer = setInterval(() => {
24 | this.setState(prevState => ({
25 | counter: prevState.counter + 1,
26 | }));
27 | }, 100);
28 | }
29 |
30 | override componentWillUnmount() {
31 | clearInterval(this.timer);
32 | }
33 | }
34 |
35 | function Test() {
36 | const {exit} = useApp();
37 | return ;
38 | }
39 |
40 | const app = render();
41 |
42 | try {
43 | await app.waitUntilExit();
44 | } catch (error: unknown) {
45 | console.log((error as any).message);
46 | }
47 |
--------------------------------------------------------------------------------
/test/fixtures/exit-on-exit.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Text, useApp} from '../../src/index.js';
3 |
4 | class Exit extends React.Component<
5 | {onExit: (error: Error) => void},
6 | {counter: number}
7 | > {
8 | timer?: NodeJS.Timeout;
9 |
10 | override state = {
11 | counter: 0,
12 | };
13 |
14 | override render() {
15 | return Counter: {this.state.counter};
16 | }
17 |
18 | override componentDidMount() {
19 | setTimeout(this.props.onExit, 500);
20 |
21 | this.timer = setInterval(() => {
22 | this.setState(prevState => ({
23 | counter: prevState.counter + 1,
24 | }));
25 | }, 100);
26 | }
27 |
28 | override componentWillUnmount() {
29 | clearInterval(this.timer);
30 | }
31 | }
32 |
33 | function Test() {
34 | const {exit} = useApp();
35 | return ;
36 | }
37 |
38 | const app = render();
39 |
40 | await app.waitUntilExit();
41 | console.log('exited');
42 |
--------------------------------------------------------------------------------
/test/fixtures/exit-on-finish.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Text} from '../../src/index.js';
3 |
4 | class Test extends React.Component, {counter: number}> {
5 | timer?: NodeJS.Timeout;
6 |
7 | override state = {
8 | counter: 0,
9 | };
10 |
11 | override render() {
12 | return Counter: {this.state.counter};
13 | }
14 |
15 | override componentDidMount() {
16 | const onTimeout = () => {
17 | if (this.state.counter > 4) {
18 | return;
19 | }
20 |
21 | this.setState(prevState => ({
22 | counter: prevState.counter + 1,
23 | }));
24 |
25 | this.timer = setTimeout(onTimeout, 100);
26 | };
27 |
28 | this.timer = setTimeout(onTimeout, 100);
29 | }
30 |
31 | override componentWillUnmount() {
32 | clearTimeout(this.timer);
33 | }
34 | }
35 |
36 | render();
37 |
--------------------------------------------------------------------------------
/test/fixtures/exit-on-unmount.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Text} from '../../src/index.js';
3 |
4 | class Test extends React.Component, {counter: number}> {
5 | timer?: NodeJS.Timeout;
6 |
7 | override state = {
8 | counter: 0,
9 | };
10 |
11 | override render() {
12 | return Counter: {this.state.counter};
13 | }
14 |
15 | override componentDidMount() {
16 | this.timer = setInterval(() => {
17 | this.setState(prevState => ({
18 | counter: prevState.counter + 1,
19 | }));
20 | }, 100);
21 | }
22 |
23 | override componentWillUnmount() {
24 | clearInterval(this.timer);
25 | }
26 | }
27 |
28 | const app = render();
29 |
30 | setTimeout(() => {
31 | app.unmount();
32 | }, 500);
33 |
34 | await app.waitUntilExit();
35 | console.log('exited');
36 |
--------------------------------------------------------------------------------
/test/fixtures/exit-raw-on-exit-with-error.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Text, useApp, useStdin} from '../../src/index.js';
3 |
4 | class Exit extends React.Component<{
5 | onSetRawMode: (value: boolean) => void;
6 | onExit: (error: Error) => void;
7 | }> {
8 | override render() {
9 | return Hello World;
10 | }
11 |
12 | override componentDidMount() {
13 | this.props.onSetRawMode(true);
14 |
15 | setTimeout(() => {
16 | this.props.onExit(new Error('errored'));
17 | }, 500);
18 | }
19 | }
20 |
21 | function Test() {
22 | const {exit} = useApp();
23 | const {setRawMode} = useStdin();
24 |
25 | return ;
26 | }
27 |
28 | const app = render();
29 |
30 | try {
31 | await app.waitUntilExit();
32 | } catch (error: unknown) {
33 | console.log((error as any).message);
34 | }
35 |
--------------------------------------------------------------------------------
/test/fixtures/exit-raw-on-exit.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Text, useApp, useStdin} from '../../src/index.js';
3 |
4 | class Exit extends React.Component<{
5 | onSetRawMode: (value: boolean) => void;
6 | onExit: (error: Error) => void;
7 | }> {
8 | override render() {
9 | return Hello World;
10 | }
11 |
12 | override componentDidMount() {
13 | this.props.onSetRawMode(true);
14 | setTimeout(this.props.onExit, 500);
15 | }
16 | }
17 |
18 | function Test() {
19 | const {exit} = useApp();
20 | const {setRawMode} = useStdin();
21 |
22 | return ;
23 | }
24 |
25 | const app = render();
26 |
27 | await app.waitUntilExit();
28 | console.log('exited');
29 |
--------------------------------------------------------------------------------
/test/fixtures/exit-raw-on-unmount.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Text, useStdin} from '../../src/index.js';
3 |
4 | class Exit extends React.Component<{
5 | onSetRawMode: (value: boolean) => void;
6 | }> {
7 | override render() {
8 | return Hello World;
9 | }
10 |
11 | override componentDidMount() {
12 | this.props.onSetRawMode(true);
13 | }
14 | }
15 |
16 | function Test() {
17 | const {setRawMode} = useStdin();
18 | return ;
19 | }
20 |
21 | const app = render();
22 |
23 | setTimeout(() => {
24 | app.unmount();
25 | }, 500);
26 |
27 | await app.waitUntilExit();
28 | console.log('exited');
29 |
--------------------------------------------------------------------------------
/test/fixtures/exit-with-thrown-error.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render} from '../../src/index.js';
3 |
4 | const Test = () => {
5 | throw new Error('errored');
6 | };
7 |
8 | const app = render();
9 |
10 | try {
11 | await app.waitUntilExit();
12 | } catch (error: unknown) {
13 | console.log((error as any).message);
14 | }
15 |
--------------------------------------------------------------------------------
/test/fixtures/use-input-ctrl-c.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, useInput, useApp} from '../../src/index.js';
3 |
4 | function UserInput() {
5 | const {exit} = useApp();
6 |
7 | useInput((input, key) => {
8 | if (input === 'c' && key.ctrl) {
9 | exit();
10 | return;
11 | }
12 |
13 | throw new Error('Crash');
14 | });
15 |
16 | return null;
17 | }
18 |
19 | const app = render(, {exitOnCtrlC: false});
20 |
21 | await app.waitUntilExit();
22 | console.log('exited');
23 |
--------------------------------------------------------------------------------
/test/fixtures/use-input-multiple.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState, useCallback, useEffect} from 'react';
2 | import {render, useInput, useApp, Text} from '../../src/index.js';
3 |
4 | function App() {
5 | const {exit} = useApp();
6 | const [input, setInput] = useState('');
7 |
8 | const handleInput = useCallback((input: string) => {
9 | setInput((previousInput: string) => previousInput + input);
10 | }, []);
11 |
12 | useInput(handleInput);
13 | useInput(handleInput, {isActive: false});
14 |
15 | useEffect(() => {
16 | setTimeout(exit, 1000);
17 | }, []);
18 |
19 | return {input};
20 | }
21 |
22 | const app = render();
23 |
24 | await app.waitUntilExit();
25 | console.log('exited');
26 |
--------------------------------------------------------------------------------
/test/fixtures/use-input.tsx:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import React from 'react';
3 | import {render, useInput, useApp} from '../../src/index.js';
4 |
5 | function UserInput({test}: {readonly test: string | undefined}) {
6 | const {exit} = useApp();
7 |
8 | useInput((input, key) => {
9 | if (test === 'lowercase' && input === 'q') {
10 | exit();
11 | return;
12 | }
13 |
14 | if (test === 'uppercase' && input === 'Q' && key.shift) {
15 | exit();
16 | return;
17 | }
18 |
19 | if (test === 'uppercase' && input === '\r' && !key.shift) {
20 | exit();
21 | return;
22 | }
23 |
24 | if (test === 'pastedCarriageReturn' && input === '\rtest') {
25 | exit();
26 | return;
27 | }
28 |
29 | if (test === 'pastedTab' && input === '\ttest') {
30 | exit();
31 | return;
32 | }
33 |
34 | if (test === 'escape' && key.escape) {
35 | exit();
36 | return;
37 | }
38 |
39 | if (test === 'ctrl' && input === 'f' && key.ctrl) {
40 | exit();
41 | return;
42 | }
43 |
44 | if (test === 'meta' && input === 'm' && key.meta) {
45 | exit();
46 | return;
47 | }
48 |
49 | if (test === 'upArrow' && key.upArrow && !key.meta) {
50 | exit();
51 | return;
52 | }
53 |
54 | if (test === 'downArrow' && key.downArrow && !key.meta) {
55 | exit();
56 | return;
57 | }
58 |
59 | if (test === 'leftArrow' && key.leftArrow && !key.meta) {
60 | exit();
61 | return;
62 | }
63 |
64 | if (test === 'rightArrow' && key.rightArrow && !key.meta) {
65 | exit();
66 | return;
67 | }
68 |
69 | if (test === 'upArrowMeta' && key.upArrow && key.meta) {
70 | exit();
71 | return;
72 | }
73 |
74 | if (test === 'downArrowMeta' && key.downArrow && key.meta) {
75 | exit();
76 | return;
77 | }
78 |
79 | if (test === 'leftArrowMeta' && key.leftArrow && key.meta) {
80 | exit();
81 | return;
82 | }
83 |
84 | if (test === 'rightArrowMeta' && key.rightArrow && key.meta) {
85 | exit();
86 | return;
87 | }
88 |
89 | if (test === 'upArrowCtrl' && key.upArrow && key.ctrl) {
90 | exit();
91 | return;
92 | }
93 |
94 | if (test === 'downArrowCtrl' && key.downArrow && key.ctrl) {
95 | exit();
96 | return;
97 | }
98 |
99 | if (test === 'leftArrowCtrl' && key.leftArrow && key.ctrl) {
100 | exit();
101 | return;
102 | }
103 |
104 | if (test === 'rightArrowCtrl' && key.rightArrow && key.ctrl) {
105 | exit();
106 | return;
107 | }
108 |
109 | if (test === 'pageDown' && key.pageDown && !key.meta) {
110 | exit();
111 | return;
112 | }
113 |
114 | if (test === 'pageUp' && key.pageUp && !key.meta) {
115 | exit();
116 | return;
117 | }
118 |
119 | if (test === 'tab' && input === '' && key.tab && !key.ctrl) {
120 | exit();
121 | return;
122 | }
123 |
124 | if (test === 'shiftTab' && input === '' && key.tab && key.shift) {
125 | exit();
126 | return;
127 | }
128 |
129 | if (test === 'backspace' && input === '' && key.backspace) {
130 | exit();
131 | return;
132 | }
133 |
134 | if (test === 'delete' && input === '' && key.delete) {
135 | exit();
136 | return;
137 | }
138 |
139 | if (test === 'remove' && input === '' && key.delete) {
140 | exit();
141 | return;
142 | }
143 |
144 | throw new Error('Crash');
145 | });
146 |
147 | return null;
148 | }
149 |
150 | const app = render();
151 |
152 | await app.waitUntilExit();
153 | console.log('exited');
154 |
--------------------------------------------------------------------------------
/test/fixtures/use-stdout.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect} from 'react';
2 | import {render, useStdout, Text} from '../../src/index.js';
3 |
4 | function WriteToStdout() {
5 | const {write} = useStdout();
6 |
7 | useEffect(() => {
8 | write('Hello from Ink to stdout\n');
9 | }, []);
10 |
11 | return Hello World;
12 | }
13 |
14 | const app = render();
15 |
16 | await app.waitUntilExit();
17 | console.log('exited');
18 |
--------------------------------------------------------------------------------
/test/flex-align-items.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import {Box, Text} from '../src/index.js';
4 | import {renderToString} from './helpers/render-to-string.js';
5 |
6 | test('row - align text to center', t => {
7 | const output = renderToString(
8 |
9 | Test
10 | ,
11 | );
12 |
13 | t.is(output, '\nTest\n');
14 | });
15 |
16 | test('row - align multiple text nodes to center', t => {
17 | const output = renderToString(
18 |
19 | A
20 | B
21 | ,
22 | );
23 |
24 | t.is(output, '\nAB\n');
25 | });
26 |
27 | test('row - align text to bottom', t => {
28 | const output = renderToString(
29 |
30 | Test
31 | ,
32 | );
33 |
34 | t.is(output, '\n\nTest');
35 | });
36 |
37 | test('row - align multiple text nodes to bottom', t => {
38 | const output = renderToString(
39 |
40 | A
41 | B
42 | ,
43 | );
44 |
45 | t.is(output, '\n\nAB');
46 | });
47 |
48 | test('column - align text to center', t => {
49 | const output = renderToString(
50 |
51 | Test
52 | ,
53 | );
54 |
55 | t.is(output, ' Test');
56 | });
57 |
58 | test('column - align text to right', t => {
59 | const output = renderToString(
60 |
61 | Test
62 | ,
63 | );
64 |
65 | t.is(output, ' Test');
66 | });
67 |
--------------------------------------------------------------------------------
/test/flex-align-self.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import {Box, Text} from '../src/index.js';
4 | import {renderToString} from './helpers/render-to-string.js';
5 |
6 | test('row - align text to center', t => {
7 | const output = renderToString(
8 |
9 |
10 | Test
11 |
12 | ,
13 | );
14 |
15 | t.is(output, '\nTest\n');
16 | });
17 |
18 | test('row - align multiple text nodes to center', t => {
19 | const output = renderToString(
20 |
21 |
22 | A
23 | B
24 |
25 | ,
26 | );
27 |
28 | t.is(output, '\nAB\n');
29 | });
30 |
31 | test('row - align text to bottom', t => {
32 | const output = renderToString(
33 |
34 |
35 | Test
36 |
37 | ,
38 | );
39 |
40 | t.is(output, '\n\nTest');
41 | });
42 |
43 | test('row - align multiple text nodes to bottom', t => {
44 | const output = renderToString(
45 |
46 |
47 | A
48 | B
49 |
50 | ,
51 | );
52 |
53 | t.is(output, '\n\nAB');
54 | });
55 |
56 | test('column - align text to center', t => {
57 | const output = renderToString(
58 |
59 |
60 | Test
61 |
62 | ,
63 | );
64 |
65 | t.is(output, ' Test');
66 | });
67 |
68 | test('column - align text to right', t => {
69 | const output = renderToString(
70 |
71 |
72 | Test
73 |
74 | ,
75 | );
76 |
77 | t.is(output, ' Test');
78 | });
79 |
--------------------------------------------------------------------------------
/test/flex-direction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import {Box, Text} from '../src/index.js';
4 | import {renderToString} from './helpers/render-to-string.js';
5 |
6 | test('direction row', t => {
7 | const output = renderToString(
8 |
9 | A
10 | B
11 | ,
12 | );
13 |
14 | t.is(output, 'AB');
15 | });
16 |
17 | test('direction row reverse', t => {
18 | const output = renderToString(
19 |
20 | A
21 | B
22 | ,
23 | );
24 |
25 | t.is(output, ' BA');
26 | });
27 |
28 | test('direction column', t => {
29 | const output = renderToString(
30 |
31 | A
32 | B
33 | ,
34 | );
35 |
36 | t.is(output, 'A\nB');
37 | });
38 |
39 | test('direction column reverse', t => {
40 | const output = renderToString(
41 |
42 | A
43 | B
44 | ,
45 | );
46 |
47 | t.is(output, '\n\nB\nA');
48 | });
49 |
50 | test('don’t squash text nodes when column direction is applied', t => {
51 | const output = renderToString(
52 |
53 | A
54 | B
55 | ,
56 | );
57 |
58 | t.is(output, 'A\nB');
59 | });
60 |
--------------------------------------------------------------------------------
/test/flex-justify-content.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import chalk from 'chalk';
4 | import {Box, Text} from '../src/index.js';
5 | import {renderToString} from './helpers/render-to-string.js';
6 |
7 | test('row - align text to center', t => {
8 | const output = renderToString(
9 |
10 | Test
11 | ,
12 | );
13 |
14 | t.is(output, ' Test');
15 | });
16 |
17 | test('row - align multiple text nodes to center', t => {
18 | const output = renderToString(
19 |
20 | A
21 | B
22 | ,
23 | );
24 |
25 | t.is(output, ' AB');
26 | });
27 |
28 | test('row - align text to right', t => {
29 | const output = renderToString(
30 |
31 | Test
32 | ,
33 | );
34 |
35 | t.is(output, ' Test');
36 | });
37 |
38 | test('row - align multiple text nodes to right', t => {
39 | const output = renderToString(
40 |
41 | A
42 | B
43 | ,
44 | );
45 |
46 | t.is(output, ' AB');
47 | });
48 |
49 | test('row - align two text nodes on the edges', t => {
50 | const output = renderToString(
51 |
52 | A
53 | B
54 | ,
55 | );
56 |
57 | t.is(output, 'A B');
58 | });
59 |
60 | test('row - space evenly two text nodes', t => {
61 | const output = renderToString(
62 |
63 | A
64 | B
65 | ,
66 | );
67 |
68 | t.is(output, ' A B');
69 | });
70 |
71 | // Yoga has a bug, where first child in a container with space-around doesn't have
72 | // the correct X coordinate and measure function is used on that child node
73 | test.failing('row - align two text nodes with equal space around them', t => {
74 | const output = renderToString(
75 |
76 | A
77 | B
78 | ,
79 | );
80 |
81 | t.is(output, ' A B');
82 | });
83 |
84 | test('row - align colored text node when text is squashed', t => {
85 | const output = renderToString(
86 |
87 | X
88 | ,
89 | );
90 |
91 | t.is(output, ` ${chalk.green('X')}`);
92 | });
93 |
94 | test('column - align text to center', t => {
95 | const output = renderToString(
96 |
97 | Test
98 | ,
99 | );
100 |
101 | t.is(output, '\nTest\n');
102 | });
103 |
104 | test('column - align text to bottom', t => {
105 | const output = renderToString(
106 |
107 | Test
108 | ,
109 | );
110 |
111 | t.is(output, '\n\nTest');
112 | });
113 |
114 | test('column - align two text nodes on the edges', t => {
115 | const output = renderToString(
116 |
117 | A
118 | B
119 | ,
120 | );
121 |
122 | t.is(output, 'A\n\n\nB');
123 | });
124 |
125 | // Yoga has a bug, where first child in a container with space-around doesn't have
126 | // the correct X coordinate and measure function is used on that child node
127 | test.failing(
128 | 'column - align two text nodes with equal space around them',
129 | t => {
130 | const output = renderToString(
131 |
132 | A
133 | B
134 | ,
135 | );
136 |
137 | t.is(output, '\nA\n\nB\n');
138 | },
139 | );
140 |
--------------------------------------------------------------------------------
/test/flex-wrap.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import {Box, Text} from '../src/index.js';
4 | import {renderToString} from './helpers/render-to-string.js';
5 |
6 | test('row - no wrap', t => {
7 | const output = renderToString(
8 |
9 | A
10 | BC
11 | ,
12 | );
13 |
14 | t.is(output, 'BC\n');
15 | });
16 |
17 | test('column - no wrap', t => {
18 | const output = renderToString(
19 |
20 | A
21 | B
22 | C
23 | ,
24 | );
25 |
26 | t.is(output, 'B\nC');
27 | });
28 |
29 | test('row - wrap content', t => {
30 | const output = renderToString(
31 |
32 | A
33 | BC
34 | ,
35 | );
36 |
37 | t.is(output, 'A\nBC');
38 | });
39 |
40 | test('column - wrap content', t => {
41 | const output = renderToString(
42 |
43 | A
44 | B
45 | C
46 | ,
47 | );
48 |
49 | t.is(output, 'AC\nB');
50 | });
51 |
52 | test('column - wrap content reverse', t => {
53 | const output = renderToString(
54 |
55 | A
56 | B
57 | C
58 | ,
59 | );
60 |
61 | t.is(output, ' CA\n B');
62 | });
63 |
64 | test('row - wrap content reverse', t => {
65 | const output = renderToString(
66 |
67 | A
68 | B
69 | C
70 | ,
71 | );
72 |
73 | t.is(output, '\nC\nAB');
74 | });
75 |
--------------------------------------------------------------------------------
/test/flex.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import {Box, Text} from '../src/index.js';
4 | import {renderToString} from './helpers/render-to-string.js';
5 |
6 | test('grow equally', t => {
7 | const output = renderToString(
8 |
9 |
10 | A
11 |
12 |
13 | B
14 |
15 | ,
16 | );
17 |
18 | t.is(output, 'A B');
19 | });
20 |
21 | test('grow one element', t => {
22 | const output = renderToString(
23 |
24 |
25 | A
26 |
27 | B
28 | ,
29 | );
30 |
31 | t.is(output, 'A B');
32 | });
33 |
34 | test('dont shrink', t => {
35 | const output = renderToString(
36 |
37 |
38 | A
39 |
40 |
41 | B
42 |
43 |
44 | C
45 |
46 | ,
47 | );
48 |
49 | t.is(output, 'A B C');
50 | });
51 |
52 | test('shrink equally', t => {
53 | const output = renderToString(
54 |
55 |
56 | A
57 |
58 |
59 | B
60 |
61 | C
62 | ,
63 | );
64 |
65 | t.is(output, 'A B C');
66 | });
67 |
68 | test('set flex basis with flexDirection="row" container', t => {
69 | const output = renderToString(
70 |
71 |
72 | A
73 |
74 | B
75 | ,
76 | );
77 |
78 | t.is(output, 'A B');
79 | });
80 |
81 | test('set flex basis in percent with flexDirection="row" container', t => {
82 | const output = renderToString(
83 |
84 |
85 | A
86 |
87 | B
88 | ,
89 | );
90 |
91 | t.is(output, 'A B');
92 | });
93 |
94 | test('set flex basis with flexDirection="column" container', t => {
95 | const output = renderToString(
96 |
97 |
98 | A
99 |
100 | B
101 | ,
102 | );
103 |
104 | t.is(output, 'A\n\n\nB\n\n');
105 | });
106 |
107 | test('set flex basis in percent with flexDirection="column" container', t => {
108 | const output = renderToString(
109 |
110 |
111 | A
112 |
113 | B
114 | ,
115 | );
116 |
117 | t.is(output, 'A\n\n\nB\n\n');
118 | });
119 |
--------------------------------------------------------------------------------
/test/gap.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import {Box, Text} from '../src/index.js';
4 | import {renderToString} from './helpers/render-to-string.js';
5 |
6 | test('gap', t => {
7 | const output = renderToString(
8 |
9 | A
10 | B
11 | C
12 | ,
13 | );
14 |
15 | t.is(output, 'A B\n\nC');
16 | });
17 |
18 | test('column gap', t => {
19 | const output = renderToString(
20 |
21 | A
22 | B
23 | ,
24 | );
25 |
26 | t.is(output, 'A B');
27 | });
28 |
29 | test('row gap', t => {
30 | const output = renderToString(
31 |
32 | A
33 | B
34 | ,
35 | );
36 |
37 | t.is(output, 'A\n\nB');
38 | });
39 |
--------------------------------------------------------------------------------
/test/helpers/create-stdout.ts:
--------------------------------------------------------------------------------
1 | import EventEmitter from 'node:events';
2 | import {spy} from 'sinon';
3 |
4 | // Fake process.stdout
5 | type FakeStdout = {
6 | get: () => string;
7 | } & NodeJS.WriteStream;
8 |
9 | const createStdout = (columns?: number): FakeStdout => {
10 | const stdout = new EventEmitter() as unknown as FakeStdout;
11 | stdout.columns = columns ?? 100;
12 |
13 | const write = spy();
14 | stdout.write = write;
15 |
16 | stdout.get = () => write.lastCall.args[0] as string;
17 |
18 | return stdout;
19 | };
20 |
21 | export default createStdout;
22 |
--------------------------------------------------------------------------------
/test/helpers/render-to-string.ts:
--------------------------------------------------------------------------------
1 | import {render} from '../../src/index.js';
2 | import createStdout from './create-stdout.js';
3 |
4 | export const renderToString: (
5 | node: React.JSX.Element,
6 | options?: {columns: number},
7 | ) => string = (node, options) => {
8 | const stdout = createStdout(options?.columns ?? 100);
9 |
10 | render(node, {
11 | stdout,
12 | debug: true,
13 | });
14 |
15 | const output = stdout.get();
16 | return output;
17 | };
18 |
--------------------------------------------------------------------------------
/test/helpers/run.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import {createRequire} from 'node:module';
3 | import path from 'node:path';
4 | import url from 'node:url';
5 |
6 | const require = createRequire(import.meta.url);
7 |
8 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports
9 | const {spawn} = require('node-pty') as typeof import('node-pty');
10 |
11 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
12 |
13 | type Run = (
14 | fixture: string,
15 | props?: {env?: Record; columns?: number},
16 | ) => Promise;
17 |
18 | export const run: Run = async (fixture, props) => {
19 | const env: Record = {
20 | ...(process.env as Record),
21 | // eslint-disable-next-line @typescript-eslint/naming-convention
22 | CI: 'false',
23 | ...props?.env,
24 | // eslint-disable-next-line @typescript-eslint/naming-convention
25 | NODE_NO_WARNINGS: '1',
26 | };
27 |
28 | return new Promise((resolve, reject) => {
29 | const term = spawn(
30 | 'node',
31 | [
32 | '--loader=ts-node/esm',
33 | path.join(__dirname, `/../fixtures/${fixture}.tsx`),
34 | ],
35 | {
36 | name: 'xterm-color',
37 | cols: typeof props?.columns === 'number' ? props.columns : 100,
38 | cwd: __dirname,
39 | env,
40 | },
41 | );
42 |
43 | let output = '';
44 |
45 | term.onData(data => {
46 | output += data;
47 | });
48 |
49 | term.onExit(({exitCode}) => {
50 | if (exitCode === 0) {
51 | resolve(output);
52 | return;
53 | }
54 |
55 | reject(new Error(`Process exited with a non-zero code: ${exitCode}`));
56 | });
57 | });
58 | };
59 |
--------------------------------------------------------------------------------
/test/hooks.tsx:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import url from 'node:url';
3 | import path from 'node:path';
4 | import test, {type ExecutionContext} from 'ava';
5 | import stripAnsi from 'strip-ansi';
6 | import {spawn} from 'node-pty';
7 |
8 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
9 |
10 | const term = (fixture: string, args: string[] = []) => {
11 | let resolve: (value?: any) => void;
12 | let reject: (error?: Error) => void;
13 |
14 | // eslint-disable-next-line promise/param-names
15 | const exitPromise = new Promise((resolve2, reject2) => {
16 | resolve = resolve2;
17 | reject = reject2;
18 | });
19 |
20 | const env: Record = {
21 | ...process.env,
22 | // eslint-disable-next-line @typescript-eslint/naming-convention
23 | NODE_NO_WARNINGS: '1',
24 | // eslint-disable-next-line @typescript-eslint/naming-convention
25 | CI: 'false',
26 | };
27 |
28 | const ps = spawn(
29 | 'node',
30 | [
31 | '--loader=ts-node/esm',
32 | path.join(__dirname, `./fixtures/${fixture}.tsx`),
33 | ...args,
34 | ],
35 | {
36 | name: 'xterm-color',
37 | cols: 100,
38 | cwd: __dirname,
39 | env,
40 | },
41 | );
42 |
43 | const result = {
44 | write(input: string) {
45 | // Give TS and Ink time to start up and render UI
46 | // TODO: Send a signal from the Ink process when it's ready to accept input instead
47 | setTimeout(() => {
48 | ps.write(input);
49 | }, 3000);
50 | },
51 | output: '',
52 | waitForExit: async () => exitPromise,
53 | };
54 |
55 | ps.onData(data => {
56 | result.output += data;
57 | });
58 |
59 | ps.onExit(({exitCode}) => {
60 | if (exitCode === 0) {
61 | resolve();
62 | return;
63 | }
64 |
65 | reject(new Error(`Process exited with non-zero exit code: ${exitCode}`));
66 | });
67 |
68 | return result;
69 | };
70 |
71 | test.serial('useInput - handle lowercase character', async t => {
72 | const ps = term('use-input', ['lowercase']);
73 | ps.write('q');
74 | await ps.waitForExit();
75 | t.true(ps.output.includes('exited'));
76 | });
77 |
78 | test.serial('useInput - handle uppercase character', async t => {
79 | const ps = term('use-input', ['uppercase']);
80 | ps.write('Q');
81 | await ps.waitForExit();
82 | t.true(ps.output.includes('exited'));
83 | });
84 |
85 | test.serial(
86 | 'useInput - \r should not count as an uppercase character',
87 | async t => {
88 | const ps = term('use-input', ['uppercase']);
89 | ps.write('\r');
90 | await ps.waitForExit();
91 | t.true(ps.output.includes('exited'));
92 | },
93 | );
94 |
95 | test.serial('useInput - pasted carriage return', async t => {
96 | const ps = term('use-input', ['pastedCarriageReturn']);
97 | ps.write('\rtest');
98 | await ps.waitForExit();
99 | t.true(ps.output.includes('exited'));
100 | });
101 |
102 | test.serial('useInput - pasted tab', async t => {
103 | const ps = term('use-input', ['pastedTab']);
104 | ps.write('\ttest');
105 | await ps.waitForExit();
106 | t.true(ps.output.includes('exited'));
107 | });
108 |
109 | test.serial('useInput - handle escape', async t => {
110 | const ps = term('use-input', ['escape']);
111 | ps.write('\u001B');
112 | await ps.waitForExit();
113 | t.true(ps.output.includes('exited'));
114 | });
115 |
116 | test.serial('useInput - handle ctrl', async t => {
117 | const ps = term('use-input', ['ctrl']);
118 | ps.write('\u0006');
119 | await ps.waitForExit();
120 | t.true(ps.output.includes('exited'));
121 | });
122 |
123 | test.serial('useInput - handle meta', async t => {
124 | const ps = term('use-input', ['meta']);
125 | ps.write('\u001Bm');
126 | await ps.waitForExit();
127 | t.true(ps.output.includes('exited'));
128 | });
129 |
130 | test.serial('useInput - handle up arrow', async t => {
131 | const ps = term('use-input', ['upArrow']);
132 | ps.write('\u001B[A');
133 | await ps.waitForExit();
134 | t.true(ps.output.includes('exited'));
135 | });
136 |
137 | test.serial('useInput - handle down arrow', async t => {
138 | const ps = term('use-input', ['downArrow']);
139 | ps.write('\u001B[B');
140 | await ps.waitForExit();
141 | t.true(ps.output.includes('exited'));
142 | });
143 |
144 | test.serial('useInput - handle left arrow', async t => {
145 | const ps = term('use-input', ['leftArrow']);
146 | ps.write('\u001B[D');
147 | await ps.waitForExit();
148 | t.true(ps.output.includes('exited'));
149 | });
150 |
151 | test.serial('useInput - handle right arrow', async t => {
152 | const ps = term('use-input', ['rightArrow']);
153 | ps.write('\u001B[C');
154 | await ps.waitForExit();
155 | t.true(ps.output.includes('exited'));
156 | });
157 |
158 | test.serial('useInput - handle meta + up arrow', async t => {
159 | const ps = term('use-input', ['upArrowMeta']);
160 | ps.write('\u001B\u001B[A');
161 | await ps.waitForExit();
162 | t.true(ps.output.includes('exited'));
163 | });
164 |
165 | test.serial('useInput - handle meta + down arrow', async t => {
166 | const ps = term('use-input', ['downArrowMeta']);
167 | ps.write('\u001B\u001B[B');
168 | await ps.waitForExit();
169 | t.true(ps.output.includes('exited'));
170 | });
171 |
172 | test.serial('useInput - handle meta + left arrow', async t => {
173 | const ps = term('use-input', ['leftArrowMeta']);
174 | ps.write('\u001B\u001B[D');
175 | await ps.waitForExit();
176 | t.true(ps.output.includes('exited'));
177 | });
178 |
179 | test.serial('useInput - handle meta + right arrow', async t => {
180 | const ps = term('use-input', ['rightArrowMeta']);
181 | ps.write('\u001B\u001B[C');
182 | await ps.waitForExit();
183 | t.true(ps.output.includes('exited'));
184 | });
185 |
186 | test.serial('useInput - handle ctrl + up arrow', async t => {
187 | const ps = term('use-input', ['upArrowCtrl']);
188 | ps.write('\u001B[1;5A');
189 | await ps.waitForExit();
190 | t.true(ps.output.includes('exited'));
191 | });
192 |
193 | test.serial('useInput - handle ctrl + down arrow', async t => {
194 | const ps = term('use-input', ['downArrowCtrl']);
195 | ps.write('\u001B[1;5B');
196 | await ps.waitForExit();
197 | t.true(ps.output.includes('exited'));
198 | });
199 |
200 | test.serial('useInput - handle ctrl + left arrow', async t => {
201 | const ps = term('use-input', ['leftArrowCtrl']);
202 | ps.write('\u001B[1;5D');
203 | await ps.waitForExit();
204 | t.true(ps.output.includes('exited'));
205 | });
206 |
207 | test.serial('useInput - handle ctrl + right arrow', async t => {
208 | const ps = term('use-input', ['rightArrowCtrl']);
209 | ps.write('\u001B[1;5C');
210 | await ps.waitForExit();
211 | t.true(ps.output.includes('exited'));
212 | });
213 |
214 | test.serial('useInput - handle page down', async t => {
215 | const ps = term('use-input', ['pageDown']);
216 | ps.write('\u001B[6~');
217 | await ps.waitForExit();
218 | t.true(ps.output.includes('exited'));
219 | });
220 |
221 | test.serial('useInput - handle page up', async t => {
222 | const ps = term('use-input', ['pageUp']);
223 | ps.write('\u001B[5~');
224 | await ps.waitForExit();
225 | t.true(ps.output.includes('exited'));
226 | });
227 |
228 | test.serial('useInput - handle tab', async t => {
229 | const ps = term('use-input', ['tab']);
230 | ps.write('\t');
231 | await ps.waitForExit();
232 | t.true(ps.output.includes('exited'));
233 | });
234 |
235 | test.serial('useInput - handle shift + tab', async t => {
236 | const ps = term('use-input', ['shiftTab']);
237 | ps.write('\u001B[Z');
238 | await ps.waitForExit();
239 | t.true(ps.output.includes('exited'));
240 | });
241 |
242 | test.serial('useInput - handle backspace', async t => {
243 | const ps = term('use-input', ['backspace']);
244 | ps.write('\u0008');
245 | await ps.waitForExit();
246 | t.true(ps.output.includes('exited'));
247 | });
248 |
249 | test.serial('useInput - handle delete', async t => {
250 | const ps = term('use-input', ['delete']);
251 | ps.write('\u007F');
252 | await ps.waitForExit();
253 | t.true(ps.output.includes('exited'));
254 | });
255 |
256 | test.serial('useInput - handle remove (delete)', async t => {
257 | const ps = term('use-input', ['remove']);
258 | ps.write('\u001B[3~');
259 | await ps.waitForExit();
260 | t.true(ps.output.includes('exited'));
261 | });
262 |
263 | test.serial('useInput - ignore input if not active', async t => {
264 | const ps = term('use-input-multiple');
265 | ps.write('x');
266 | await ps.waitForExit();
267 | t.false(ps.output.includes('xx'));
268 | t.true(ps.output.includes('x'));
269 | t.true(ps.output.includes('exited'));
270 | });
271 |
272 | // For some reason this test is flaky, so we have to resort to using `t.try` to run it multiple times
273 | test.serial(
274 | 'useInput - handle Ctrl+C when `exitOnCtrlC` is `false`',
275 | async t => {
276 | const run = async (tt: ExecutionContext) => {
277 | const ps = term('use-input-ctrl-c');
278 | ps.write('\u0003');
279 | await ps.waitForExit();
280 | tt.true(ps.output.includes('exited'));
281 | };
282 |
283 | const firstTry = await t.try(run);
284 |
285 | if (firstTry.passed) {
286 | firstTry.commit();
287 | return;
288 | }
289 |
290 | firstTry.discard();
291 |
292 | const secondTry = await t.try(run);
293 |
294 | if (secondTry.passed) {
295 | secondTry.commit();
296 | return;
297 | }
298 |
299 | secondTry.discard();
300 |
301 | const thirdTry = await t.try(run);
302 | thirdTry.commit();
303 | },
304 | );
305 |
306 | test.serial('useStdout - write to stdout', async t => {
307 | const ps = term('use-stdout');
308 | await ps.waitForExit();
309 |
310 | const lines = stripAnsi(ps.output).split('\r\n');
311 |
312 | t.deepEqual(lines.slice(1, -1), [
313 | 'Hello from Ink to stdout',
314 | 'Hello World',
315 | 'exited',
316 | ]);
317 | });
318 |
319 | // `node-pty` doesn't support streaming stderr output, so I need to figure out
320 | // how to test useStderr() hook. child_process.spawn() can't be used, because
321 | // Ink fails with "raw mode unsupported" error.
322 | test.todo('useStderr - write to stderr');
323 |
--------------------------------------------------------------------------------
/test/margin.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import {Box, Text} from '../src/index.js';
4 | import {renderToString} from './helpers/render-to-string.js';
5 |
6 | test('margin', t => {
7 | const output = renderToString(
8 |
9 | X
10 | ,
11 | );
12 |
13 | t.is(output, '\n\n X\n\n');
14 | });
15 |
16 | test('margin X', t => {
17 | const output = renderToString(
18 |
19 |
20 | X
21 |
22 | Y
23 | ,
24 | );
25 |
26 | t.is(output, ' X Y');
27 | });
28 |
29 | test('margin Y', t => {
30 | const output = renderToString(
31 |
32 | X
33 | ,
34 | );
35 |
36 | t.is(output, '\n\nX\n\n');
37 | });
38 |
39 | test('margin top', t => {
40 | const output = renderToString(
41 |
42 | X
43 | ,
44 | );
45 |
46 | t.is(output, '\n\nX');
47 | });
48 |
49 | test('margin bottom', t => {
50 | const output = renderToString(
51 |
52 | X
53 | ,
54 | );
55 |
56 | t.is(output, 'X\n\n');
57 | });
58 |
59 | test('margin left', t => {
60 | const output = renderToString(
61 |
62 | X
63 | ,
64 | );
65 |
66 | t.is(output, ' X');
67 | });
68 |
69 | test('margin right', t => {
70 | const output = renderToString(
71 |
72 |
73 | X
74 |
75 | Y
76 | ,
77 | );
78 |
79 | t.is(output, 'X Y');
80 | });
81 |
82 | test('nested margin', t => {
83 | const output = renderToString(
84 |
85 |
86 | X
87 |
88 | ,
89 | );
90 |
91 | t.is(output, '\n\n\n\n X\n\n\n\n');
92 | });
93 |
94 | test('margin with multiline string', t => {
95 | const output = renderToString(
96 |
97 | {'A\nB'}
98 | ,
99 | );
100 |
101 | t.is(output, '\n\n A\n B\n\n');
102 | });
103 |
104 | test('apply margin to text with newlines', t => {
105 | const output = renderToString(
106 |
107 | Hello{'\n'}World
108 | ,
109 | );
110 | t.is(output, '\n Hello\n World\n');
111 | });
112 |
113 | test('apply margin to wrapped text', t => {
114 | const output = renderToString(
115 |
116 | Hello World
117 | ,
118 | );
119 |
120 | t.is(output, '\n Hello\n World\n');
121 | });
122 |
--------------------------------------------------------------------------------
/test/measure-element.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState, useRef, useEffect} from 'react';
2 | import test from 'ava';
3 | import delay from 'delay';
4 | import stripAnsi from 'strip-ansi';
5 | import {
6 | Box,
7 | Text,
8 | render,
9 | measureElement,
10 | type DOMElement,
11 | } from '../src/index.js';
12 | import createStdout from './helpers/create-stdout.js';
13 |
14 | test('measure element', async t => {
15 | const stdout = createStdout();
16 |
17 | function Test() {
18 | const [width, setWidth] = useState(0);
19 | const ref = useRef(null);
20 |
21 | useEffect(() => {
22 | if (!ref.current) {
23 | return;
24 | }
25 |
26 | setWidth(measureElement(ref.current).width);
27 | }, []);
28 |
29 | return (
30 |
31 | Width: {width}
32 |
33 | );
34 | }
35 |
36 | render(, {stdout, debug: true});
37 | t.is((stdout.write as any).firstCall.args[0], 'Width: 0');
38 | await delay(100);
39 | t.is((stdout.write as any).lastCall.args[0], 'Width: 100');
40 | });
41 |
42 | test.serial('calculate layout while rendering is throttled', async t => {
43 | const stdout = createStdout();
44 |
45 | function Test() {
46 | const [width, setWidth] = useState(0);
47 | const ref = useRef(null);
48 |
49 | useEffect(() => {
50 | if (!ref.current) {
51 | return;
52 | }
53 |
54 | setWidth(measureElement(ref.current).width);
55 | }, []);
56 |
57 | return (
58 |
59 | Width: {width}
60 |
61 | );
62 | }
63 |
64 | const {rerender} = render(null, {stdout, patchConsole: false});
65 | rerender();
66 | await delay(50);
67 |
68 | t.is(
69 | stripAnsi((stdout.write as any).lastCall.firstArg as string).trim(),
70 | 'Width: 100',
71 | );
72 | });
73 |
--------------------------------------------------------------------------------
/test/padding.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import {Box, Text} from '../src/index.js';
4 | import {renderToString} from './helpers/render-to-string.js';
5 |
6 | test('padding', t => {
7 | const output = renderToString(
8 |
9 | X
10 | ,
11 | );
12 |
13 | t.is(output, '\n\n X\n\n');
14 | });
15 |
16 | test('padding X', t => {
17 | const output = renderToString(
18 |
19 |
20 | X
21 |
22 | Y
23 | ,
24 | );
25 |
26 | t.is(output, ' X Y');
27 | });
28 |
29 | test('padding Y', t => {
30 | const output = renderToString(
31 |
32 | X
33 | ,
34 | );
35 |
36 | t.is(output, '\n\nX\n\n');
37 | });
38 |
39 | test('padding top', t => {
40 | const output = renderToString(
41 |
42 | X
43 | ,
44 | );
45 |
46 | t.is(output, '\n\nX');
47 | });
48 |
49 | test('padding bottom', t => {
50 | const output = renderToString(
51 |
52 | X
53 | ,
54 | );
55 |
56 | t.is(output, 'X\n\n');
57 | });
58 |
59 | test('padding left', t => {
60 | const output = renderToString(
61 |
62 | X
63 | ,
64 | );
65 |
66 | t.is(output, ' X');
67 | });
68 |
69 | test('padding right', t => {
70 | const output = renderToString(
71 |
72 |
73 | X
74 |
75 | Y
76 | ,
77 | );
78 |
79 | t.is(output, 'X Y');
80 | });
81 |
82 | test('nested padding', t => {
83 | const output = renderToString(
84 |
85 |
86 | X
87 |
88 | ,
89 | );
90 |
91 | t.is(output, '\n\n\n\n X\n\n\n\n');
92 | });
93 |
94 | test('padding with multiline string', t => {
95 | const output = renderToString(
96 |
97 | {'A\nB'}
98 | ,
99 | );
100 |
101 | t.is(output, '\n\n A\n B\n\n');
102 | });
103 |
104 | test('apply padding to text with newlines', t => {
105 | const output = renderToString(
106 |
107 | Hello{'\n'}World
108 | ,
109 | );
110 | t.is(output, '\n Hello\n World\n');
111 | });
112 |
113 | test('apply padding to wrapped text', t => {
114 | const output = renderToString(
115 |
116 | Hello World
117 | ,
118 | );
119 |
120 | t.is(output, '\n Hel\n lo\n Wor\n ld\n');
121 | });
122 |
--------------------------------------------------------------------------------
/test/reconciler.tsx:
--------------------------------------------------------------------------------
1 | import React, {Suspense} from 'react';
2 | import test from 'ava';
3 | import chalk from 'chalk';
4 | import {Box, Text, render} from '../src/index.js';
5 | import createStdout from './helpers/create-stdout.js';
6 |
7 | test('update child', t => {
8 | function Test({update}: {readonly update?: boolean}) {
9 | return {update ? 'B' : 'A'};
10 | }
11 |
12 | const stdoutActual = createStdout();
13 | const stdoutExpected = createStdout();
14 |
15 | const actual = render(, {
16 | stdout: stdoutActual,
17 | debug: true,
18 | });
19 |
20 | const expected = render(A, {
21 | stdout: stdoutExpected,
22 | debug: true,
23 | });
24 |
25 | t.is(
26 | (stdoutActual.write as any).lastCall.args[0],
27 | (stdoutExpected.write as any).lastCall.args[0],
28 | );
29 |
30 | actual.rerender();
31 | expected.rerender(B);
32 |
33 | t.is(
34 | (stdoutActual.write as any).lastCall.args[0],
35 | (stdoutExpected.write as any).lastCall.args[0],
36 | );
37 | });
38 |
39 | test('update text node', t => {
40 | function Test({update}: {readonly update?: boolean}) {
41 | return (
42 |
43 | Hello
44 | {update ? 'B' : 'A'}
45 |
46 | );
47 | }
48 |
49 | const stdoutActual = createStdout();
50 | const stdoutExpected = createStdout();
51 |
52 | const actual = render(, {
53 | stdout: stdoutActual,
54 | debug: true,
55 | });
56 |
57 | const expected = render(Hello A, {
58 | stdout: stdoutExpected,
59 | debug: true,
60 | });
61 |
62 | t.is(
63 | (stdoutActual.write as any).lastCall.args[0],
64 | (stdoutExpected.write as any).lastCall.args[0],
65 | );
66 |
67 | actual.rerender();
68 | expected.rerender(Hello B);
69 |
70 | t.is(
71 | (stdoutActual.write as any).lastCall.args[0],
72 | (stdoutExpected.write as any).lastCall.args[0],
73 | );
74 | });
75 |
76 | test('append child', t => {
77 | function Test({append}: {readonly append?: boolean}) {
78 | if (append) {
79 | return (
80 |
81 | A
82 | B
83 |
84 | );
85 | }
86 |
87 | return (
88 |
89 | A
90 |
91 | );
92 | }
93 |
94 | const stdoutActual = createStdout();
95 | const stdoutExpected = createStdout();
96 |
97 | const actual = render(, {
98 | stdout: stdoutActual,
99 | debug: true,
100 | });
101 |
102 | const expected = render(
103 |
104 | A
105 | ,
106 | {
107 | stdout: stdoutExpected,
108 | debug: true,
109 | },
110 | );
111 |
112 | t.is(
113 | (stdoutActual.write as any).lastCall.args[0],
114 | (stdoutExpected.write as any).lastCall.args[0],
115 | );
116 |
117 | actual.rerender();
118 |
119 | expected.rerender(
120 |
121 | A
122 | B
123 | ,
124 | );
125 |
126 | t.is(
127 | (stdoutActual.write as any).lastCall.args[0],
128 | (stdoutExpected.write as any).lastCall.args[0],
129 | );
130 | });
131 |
132 | test('insert child between other children', t => {
133 | function Test({insert}: {readonly insert?: boolean}) {
134 | if (insert) {
135 | return (
136 |
137 | A
138 | B
139 | C
140 |
141 | );
142 | }
143 |
144 | return (
145 |
146 | A
147 | C
148 |
149 | );
150 | }
151 |
152 | const stdoutActual = createStdout();
153 | const stdoutExpected = createStdout();
154 |
155 | const actual = render(, {
156 | stdout: stdoutActual,
157 | debug: true,
158 | });
159 |
160 | const expected = render(
161 |
162 | A
163 | C
164 | ,
165 | {
166 | stdout: stdoutExpected,
167 | debug: true,
168 | },
169 | );
170 |
171 | t.is(
172 | (stdoutActual.write as any).lastCall.args[0],
173 | (stdoutExpected.write as any).lastCall.args[0],
174 | );
175 |
176 | actual.rerender();
177 |
178 | expected.rerender(
179 |
180 | A
181 | B
182 | C
183 | ,
184 | );
185 |
186 | t.is(
187 | (stdoutActual.write as any).lastCall.args[0],
188 | (stdoutExpected.write as any).lastCall.args[0],
189 | );
190 | });
191 |
192 | test('remove child', t => {
193 | function Test({remove}: {readonly remove?: boolean}) {
194 | if (remove) {
195 | return (
196 |
197 | A
198 |
199 | );
200 | }
201 |
202 | return (
203 |
204 | A
205 | B
206 |
207 | );
208 | }
209 |
210 | const stdoutActual = createStdout();
211 | const stdoutExpected = createStdout();
212 |
213 | const actual = render(, {
214 | stdout: stdoutActual,
215 | debug: true,
216 | });
217 |
218 | const expected = render(
219 |
220 | A
221 | B
222 | ,
223 | {
224 | stdout: stdoutExpected,
225 | debug: true,
226 | },
227 | );
228 |
229 | t.is(
230 | (stdoutActual.write as any).lastCall.args[0],
231 | (stdoutExpected.write as any).lastCall.args[0],
232 | );
233 |
234 | actual.rerender();
235 |
236 | expected.rerender(
237 |
238 | A
239 | ,
240 | );
241 |
242 | t.is(
243 | (stdoutActual.write as any).lastCall.args[0],
244 | (stdoutExpected.write as any).lastCall.args[0],
245 | );
246 | });
247 |
248 | test('reorder children', t => {
249 | function Test({reorder}: {readonly reorder?: boolean}) {
250 | if (reorder) {
251 | return (
252 |
253 | B
254 | A
255 |
256 | );
257 | }
258 |
259 | return (
260 |
261 | A
262 | B
263 |
264 | );
265 | }
266 |
267 | const stdoutActual = createStdout();
268 | const stdoutExpected = createStdout();
269 |
270 | const actual = render(, {
271 | stdout: stdoutActual,
272 | debug: true,
273 | });
274 |
275 | const expected = render(
276 |
277 | A
278 | B
279 | ,
280 | {
281 | stdout: stdoutExpected,
282 | debug: true,
283 | },
284 | );
285 |
286 | t.is(
287 | (stdoutActual.write as any).lastCall.args[0],
288 | (stdoutExpected.write as any).lastCall.args[0],
289 | );
290 |
291 | actual.rerender();
292 |
293 | expected.rerender(
294 |
295 | B
296 | A
297 | ,
298 | );
299 |
300 | t.is(
301 | (stdoutActual.write as any).lastCall.args[0],
302 | (stdoutExpected.write as any).lastCall.args[0],
303 | );
304 | });
305 |
306 | test('replace child node with text', t => {
307 | const stdout = createStdout();
308 |
309 | function Dynamic({replace}: {readonly replace?: boolean}) {
310 | return {replace ? 'x' : test};
311 | }
312 |
313 | const {rerender} = render(, {
314 | stdout,
315 | debug: true,
316 | });
317 |
318 | t.is((stdout.write as any).lastCall.args[0], chalk.green('test'));
319 |
320 | rerender();
321 | t.is((stdout.write as any).lastCall.args[0], 'x');
322 | });
323 |
324 | test('support suspense', async t => {
325 | const stdout = createStdout();
326 |
327 | let promise: Promise | undefined;
328 | let state: 'pending' | 'done' | undefined;
329 | let value: string | undefined;
330 |
331 | const read = () => {
332 | if (!promise) {
333 | promise = new Promise(resolve => {
334 | setTimeout(resolve, 500);
335 | });
336 |
337 | state = 'pending';
338 |
339 | (async () => {
340 | await promise;
341 | state = 'done';
342 | value = 'Hello World';
343 | })();
344 | }
345 |
346 | if (state === 'done') {
347 | return value;
348 | }
349 |
350 | // eslint-disable-next-line @typescript-eslint/only-throw-error
351 | throw promise;
352 | };
353 |
354 | function Suspendable() {
355 | return {read()};
356 | }
357 |
358 | function Test() {
359 | return (
360 | Loading}>
361 |
362 |
363 | );
364 | }
365 |
366 | const out = render(, {
367 | stdout,
368 | debug: true,
369 | });
370 |
371 | t.is((stdout.write as any).lastCall.args[0], 'Loading');
372 |
373 | await promise;
374 | out.rerender();
375 |
376 | t.is((stdout.write as any).lastCall.args[0], 'Hello World');
377 | });
378 |
--------------------------------------------------------------------------------
/test/render.tsx:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import url from 'node:url';
3 | import * as path from 'node:path';
4 | import {createRequire} from 'node:module';
5 | import test from 'ava';
6 | import React from 'react';
7 | import ansiEscapes from 'ansi-escapes';
8 | import stripAnsi from 'strip-ansi';
9 | import boxen from 'boxen';
10 | import delay from 'delay';
11 | import {render, Box, Text} from '../src/index.js';
12 | import createStdout from './helpers/create-stdout.js';
13 |
14 | const require = createRequire(import.meta.url);
15 |
16 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports
17 | const {spawn} = require('node-pty') as typeof import('node-pty');
18 |
19 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
20 |
21 | const term = (fixture: string, args: string[] = []) => {
22 | let resolve: (value?: unknown) => void;
23 | let reject: (error: Error) => void;
24 |
25 | // eslint-disable-next-line promise/param-names
26 | const exitPromise = new Promise((resolve2, reject2) => {
27 | resolve = resolve2;
28 | reject = reject2;
29 | });
30 |
31 | const env = {
32 | ...process.env,
33 | // eslint-disable-next-line @typescript-eslint/naming-convention
34 | NODE_NO_WARNINGS: '1',
35 | };
36 |
37 | const ps = spawn(
38 | 'node',
39 | [
40 | '--loader=ts-node/esm',
41 | path.join(__dirname, `./fixtures/${fixture}.tsx`),
42 | ...args,
43 | ],
44 | {
45 | name: 'xterm-color',
46 | cols: 100,
47 | cwd: __dirname,
48 | env,
49 | },
50 | );
51 |
52 | const result = {
53 | write(input: string) {
54 | ps.write(input);
55 | },
56 | output: '',
57 | waitForExit: async () => exitPromise,
58 | };
59 |
60 | ps.onData(data => {
61 | result.output += data;
62 | });
63 |
64 | ps.onExit(({exitCode}) => {
65 | if (exitCode === 0) {
66 | resolve();
67 | return;
68 | }
69 |
70 | reject(new Error(`Process exited with non-zero exit code: ${exitCode}`));
71 | });
72 |
73 | return result;
74 | };
75 |
76 | test.serial('do not erase screen', async t => {
77 | const ps = term('erase', ['4']);
78 | await ps.waitForExit();
79 | t.false(ps.output.includes(ansiEscapes.clearTerminal));
80 |
81 | for (const letter of ['A', 'B', 'C']) {
82 | t.true(ps.output.includes(letter));
83 | }
84 | });
85 |
86 | test.serial(
87 | 'do not erase screen where is taller than viewport',
88 | async t => {
89 | const ps = term('erase-with-static', ['4']);
90 |
91 | await ps.waitForExit();
92 | t.false(ps.output.includes(ansiEscapes.clearTerminal));
93 |
94 | for (const letter of ['A', 'B', 'C', 'D', 'E', 'F']) {
95 | t.true(ps.output.includes(letter));
96 | }
97 | },
98 | );
99 |
100 | test.serial('erase screen', async t => {
101 | const ps = term('erase', ['3']);
102 | await ps.waitForExit();
103 | t.true(ps.output.includes(ansiEscapes.clearTerminal));
104 |
105 | for (const letter of ['A', 'B', 'C']) {
106 | t.true(ps.output.includes(letter));
107 | }
108 | });
109 |
110 | test.serial(
111 | 'erase screen where exists but interactive part is taller than viewport',
112 | async t => {
113 | const ps = term('erase', ['3']);
114 | await ps.waitForExit();
115 | t.true(ps.output.includes(ansiEscapes.clearTerminal));
116 |
117 | for (const letter of ['A', 'B', 'C']) {
118 | t.true(ps.output.includes(letter));
119 | }
120 | },
121 | );
122 |
123 | test.serial('erase screen where state changes', async t => {
124 | const ps = term('erase-with-state-change', ['4']);
125 | await ps.waitForExit();
126 |
127 | const secondFrame = ps.output.split(ansiEscapes.eraseLines(3))[1];
128 |
129 | for (const letter of ['A', 'B', 'C']) {
130 | t.false(secondFrame?.includes(letter));
131 | }
132 | });
133 |
134 | test.serial('erase screen where state changes in small viewport', async t => {
135 | const ps = term('erase-with-state-change', ['3']);
136 | await ps.waitForExit();
137 |
138 | const frames = ps.output.split(ansiEscapes.clearTerminal);
139 | const lastFrame = frames.at(-1);
140 |
141 | for (const letter of ['A', 'B', 'C']) {
142 | t.false(lastFrame?.includes(letter));
143 | }
144 | });
145 |
146 | test.serial('clear output', async t => {
147 | const ps = term('clear');
148 | await ps.waitForExit();
149 |
150 | const secondFrame = ps.output.split(ansiEscapes.eraseLines(4))[1];
151 |
152 | for (const letter of ['A', 'B', 'C']) {
153 | t.false(secondFrame?.includes(letter));
154 | }
155 | });
156 |
157 | test.serial(
158 | 'intercept console methods and display result above output',
159 | async t => {
160 | const ps = term('console');
161 | await ps.waitForExit();
162 |
163 | const frames = ps.output.split(ansiEscapes.eraseLines(2)).map(line => {
164 | return stripAnsi(line);
165 | });
166 |
167 | t.deepEqual(frames, [
168 | 'Hello World\r\n',
169 | 'First log\r\nHello World\r\nSecond log\r\n',
170 | ]);
171 | },
172 | );
173 |
174 | test.serial('rerender on resize', async t => {
175 | const stdout = createStdout(10);
176 |
177 | function Test() {
178 | return (
179 |
180 | Test
181 |
182 | );
183 | }
184 |
185 | const {unmount} = render(, {stdout});
186 |
187 | t.is(
188 | stripAnsi((stdout.write as any).firstCall.args[0] as string),
189 | boxen('Test'.padEnd(8), {borderStyle: 'round'}) + '\n',
190 | );
191 |
192 | t.is(stdout.listeners('resize').length, 1);
193 |
194 | stdout.columns = 8;
195 | stdout.emit('resize');
196 | await delay(100);
197 |
198 | t.is(
199 | stripAnsi((stdout.write as any).lastCall.args[0] as string),
200 | boxen('Test'.padEnd(6), {borderStyle: 'round'}) + '\n',
201 | );
202 |
203 | unmount();
204 | t.is(stdout.listeners('resize').length, 0);
205 | });
206 |
--------------------------------------------------------------------------------
/test/text.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import chalk from 'chalk';
4 | import {render, Box, Text} from '../src/index.js';
5 | import {renderToString} from './helpers/render-to-string.js';
6 | import createStdout from './helpers/create-stdout.js';
7 |
8 | test(' with undefined children', t => {
9 | const output = renderToString();
10 | t.is(output, '');
11 | });
12 |
13 | test(' with null children', t => {
14 | const output = renderToString({null});
15 | t.is(output, '');
16 | });
17 |
18 | test('text with standard color', t => {
19 | const output = renderToString(Test);
20 | t.is(output, chalk.green('Test'));
21 | });
22 |
23 | test('text with dimmed color', t => {
24 | const output = renderToString(
25 |
26 | Test
27 | ,
28 | );
29 |
30 | t.is(output, chalk.green.dim('Test'));
31 | });
32 |
33 | test('text with hex color', t => {
34 | const output = renderToString(Test);
35 | t.is(output, chalk.hex('#FF8800')('Test'));
36 | });
37 |
38 | test('text with rgb color', t => {
39 | const output = renderToString(Test);
40 | t.is(output, chalk.rgb(255, 136, 0)('Test'));
41 | });
42 |
43 | test('text with ansi256 color', t => {
44 | const output = renderToString(Test);
45 | t.is(output, chalk.ansi256(194)('Test'));
46 | });
47 |
48 | test('text with standard background color', t => {
49 | const output = renderToString(Test);
50 | t.is(output, chalk.bgGreen('Test'));
51 | });
52 |
53 | test('text with hex background color', t => {
54 | const output = renderToString(Test);
55 | t.is(output, chalk.bgHex('#FF8800')('Test'));
56 | });
57 |
58 | test('text with rgb background color', t => {
59 | const output = renderToString(
60 | Test,
61 | );
62 |
63 | t.is(output, chalk.bgRgb(255, 136, 0)('Test'));
64 | });
65 |
66 | test('text with ansi256 background color', t => {
67 | const output = renderToString(
68 | Test,
69 | );
70 |
71 | t.is(output, chalk.bgAnsi256(194)('Test'));
72 | });
73 |
74 | test('text with inversion', t => {
75 | const output = renderToString(Test);
76 | t.is(output, chalk.inverse('Test'));
77 | });
78 |
79 | test('remeasure text when text is changed', t => {
80 | function Test({add}: {readonly add?: boolean}) {
81 | return (
82 |
83 | {add ? 'abcx' : 'abc'}
84 |
85 | );
86 | }
87 |
88 | const stdout = createStdout();
89 | const {rerender} = render(, {stdout, debug: true});
90 | t.is((stdout.write as any).lastCall.args[0], 'abc');
91 |
92 | rerender();
93 | t.is((stdout.write as any).lastCall.args[0], 'abcx');
94 | });
95 |
96 | test('remeasure text when text nodes are changed', t => {
97 | function Test({add}: {readonly add?: boolean}) {
98 | return (
99 |
100 |
101 | abc
102 | {add && x}
103 |
104 |
105 | );
106 | }
107 |
108 | const stdout = createStdout();
109 |
110 | const {rerender} = render(, {stdout, debug: true});
111 | t.is((stdout.write as any).lastCall.args[0], 'abc');
112 |
113 | rerender();
114 | t.is((stdout.write as any).lastCall.args[0], 'abcx');
115 | });
116 |
--------------------------------------------------------------------------------
/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "include": ["."]
4 | }
5 |
--------------------------------------------------------------------------------
/test/width-height.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import test from 'ava';
3 | import {Box, Text} from '../src/index.js';
4 | import {renderToString} from './helpers/render-to-string.js';
5 |
6 | test('set width', t => {
7 | const output = renderToString(
8 |
9 |
10 | A
11 |
12 | B
13 | ,
14 | );
15 |
16 | t.is(output, 'A B');
17 | });
18 |
19 | test('set width in percent', t => {
20 | const output = renderToString(
21 |
22 |
23 | A
24 |
25 | B
26 | ,
27 | );
28 |
29 | t.is(output, 'A B');
30 | });
31 |
32 | test('set min width', t => {
33 | const smallerOutput = renderToString(
34 |
35 |
36 | A
37 |
38 | B
39 | ,
40 | );
41 |
42 | t.is(smallerOutput, 'A B');
43 |
44 | const largerOutput = renderToString(
45 |
46 |
47 | AAAAA
48 |
49 | B
50 | ,
51 | );
52 |
53 | t.is(largerOutput, 'AAAAAB');
54 | });
55 |
56 | test.failing('set min width in percent', t => {
57 | const output = renderToString(
58 |
59 |
60 | A
61 |
62 | B
63 | ,
64 | );
65 |
66 | t.is(output, 'A B');
67 | });
68 |
69 | test('set height', t => {
70 | const output = renderToString(
71 |
72 | A
73 | B
74 | ,
75 | );
76 |
77 | t.is(output, 'AB\n\n\n');
78 | });
79 |
80 | test('set height in percent', t => {
81 | const output = renderToString(
82 |
83 |
84 | A
85 |
86 | B
87 | ,
88 | );
89 |
90 | t.is(output, 'A\n\n\nB\n\n');
91 | });
92 |
93 | test('cut text over the set height', t => {
94 | const output = renderToString(
95 |
96 | AAAABBBBCCCC
97 | ,
98 | {columns: 4},
99 | );
100 |
101 | t.is(output, 'AAAA\nBBBB');
102 | });
103 |
104 | test('set min height', t => {
105 | const smallerOutput = renderToString(
106 |
107 | A
108 | ,
109 | );
110 |
111 | t.is(smallerOutput, 'A\n\n\n');
112 |
113 | const largerOutput = renderToString(
114 |
115 |
116 | A
117 |
118 | ,
119 | );
120 |
121 | t.is(largerOutput, 'A\n\n\n');
122 | });
123 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@sindresorhus/tsconfig",
3 | "compilerOptions": {
4 | "outDir": "build",
5 | "lib": [
6 | "DOM",
7 | "DOM.Iterable",
8 | "ES2023"
9 | ],
10 | "sourceMap": true,
11 | "jsx": "react",
12 | "isolatedModules": true
13 | },
14 | "include": ["src"],
15 | "ts-node": {
16 | "transpileOnly": true,
17 | "files": true,
18 | "experimentalResolver": true,
19 | "experimentalSpecifierResolution": "node"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------