Skip to content

Commit fac1a17

Browse files
authored
feat: Add TTY and test adapters (#53)
* add TTY and test adapters * add docs and make a small refactor * added more in-code docs * refactor exit handlers * rename `Text` to `View` * update README
1 parent db2d148 commit fac1a17

23 files changed

+628
-265
lines changed

README.md

Lines changed: 117 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,51 +18,151 @@ yarn add react-stream-renderer
1818

1919
```js
2020
import React from 'react';
21-
import { render, Text } from 'react-stream-renderer';
21+
import { render, View, makeTTYAdapter } from 'react-stream-renderer';
2222

2323
class App extends React.Component {
2424
render() {
2525
return (
26-
<Text style={{ color: 'green' }}>
26+
<View style={{ color: 'green' }}>
2727
Hello world!
28-
</Text>
28+
</View>
2929
);
3030
}
3131
}
3232

33-
render(<App />, process.stdout);
33+
render(<App />, makeTTYAdapter(process.stdout).makeEffects());
3434
```
3535

3636
## API
3737
---
3838

3939
## Functions
4040

41-
### `render(element, writableStream, options): void`
41+
### `render(element: React.Element, adapter: BaseAdapter): () => void`
42+
43+
Render given element using a given adapter (`makeTTYAdapter` for TTY streams or `makeTestAdapter` for testing purposes).
44+
45+
Returns `forceRender` function which triggers full re-render.
46+
47+
### `makeTTYAdapter(ttyStream: NodeTTYStream): TTYAdapter`
48+
49+
Creates an adapter a TTY Node stream.
50+
Use chainable API from `TTYAdapter` for configuring the behavior:
51+
52+
#### `withCustomConsole(options: OverwriteConsoleOptions): TTYAdapter`
53+
54+
Redirect console output to specified streams or files.
55+
56+
This method won't have any effect unless `makeEffects` is called.
57+
58+
`options: OverwriteConsoleOptions`:
59+
60+
* `exitOnWarning: boolean` - Exit on first call to `console.warning`.
61+
* `exitOnError: boolean` - Exit on first call to `console.error`.
62+
* `outStream: boolean | string | NodeWritableStream` - Redirect console output to:
63+
* `stdout.log` if `true`
64+
* custom file if `string` with path is supplied
65+
* custom Node stream if writable node stream is supplied
66+
* `errStream: boolean | string | NodeWritableStream` - Redirect console error output to:
67+
* `stderr.log` if `true`
68+
* custom file if `string` with path is supplied
69+
* custom Node stream if writable node stream is supplied
70+
71+
#### `hideCursor(): TTYAdapter`
72+
73+
Hides cursor.
74+
75+
This method won't have any effect unless `makeEffects` is called.
76+
77+
#### `clearOnExit(shouldClearScrollback: boolean = false): TTYAdapter`
78+
79+
Clear screen or scrollback (if `shouldClearScrollback` is `true`) when process is about to exit.
80+
81+
This method won't have any effect unless `makeEffects` is called.
82+
83+
#### `clearOnError(): TTYAdapter`
84+
85+
Clear screen (scrollback will be untouched) when process is about to exit due to an error.
86+
87+
This method won't have any effect unless `makeEffects` is called.
88+
89+
#### `makeEffects(): TTYAdapter`
90+
91+
Perform accumulated side effects.
92+
__This method must always be called!__
93+
94+
__Example__
95+
96+
```js
97+
render(
98+
<View>Hello world</View>,
99+
makeTTYAdapter(process.stdout)
100+
.withCustomConsole({ outStream: true, errStream: true })
101+
.hideCursor()
102+
.clearOnExit(true)
103+
.makeEffects()
104+
);
105+
```
106+
107+
### `makeTestAdapter(options: Options): TestAdapter`
108+
109+
Creates an adapter for testing. You can provide hooks to assert the rendered content.
110+
111+
#### `options: Options`
112+
113+
* `height: number = 40` - Canvas height (default: `40`)
114+
* `width: number = 80` - Canvas width (default: `80`)
115+
* `onPrint?: (data: string) => void` - Testing hook executed on each render, `data` will be a string with full rendered content.
116+
* `onClear: () => void` - Testing hook executed when canvas should be cleared.
117+
* `onSetCursorPosition: (x: number, y: number) => void`- Testing hook executed when cursor should be changed.
42118

43-
Render given element to writable (Node) stream.
119+
#### Example
120+
121+
```js
122+
test('render should draw content', () => {
123+
const onDraw = jest.fn();
124+
const adapter = makeTestAdapter({ onDraw });
44125

45-
#### `options`
126+
render(<View>Test</View>, adapter);
46127

47-
* `hideCursor?: boolean` - Hide cursor if true.
48-
* `clearOnError?: boolean` - Clear screen when process exits due to error being thrown.
49-
* `clearScreenOnExit?: boolean` - Clear screen when process is about to exit.
50-
* `clearScrollbackOnExit?: boolean` - Clear scrollback when process is about to exit (__clearing scrollback also clears the whole screen__).
51-
* `exitOnWarning?: boolean` - Exit when there's a call to `console.warn`.
52-
* `exitOnError?: boolean` - Exit when there's a call to `console.error`.
53-
* `outStream?: any` - Custom writable stream or file path for output from `console`.
54-
* `errStream?: any` - Custom writable stream or file path for errors logged with `console.error`.
128+
expect(onDraw).toHaveBeenCalledWith('Test');
129+
});
130+
```
55131

56132
## Components
57133

58-
### `Text`
134+
### `View` (aka `Text`)
135+
136+
Basic building block. Can render text (strings), arrays and other nested components.
59137

60-
Basic building block. Can render text (strings) or other nested components.
138+
`Text` component is exposed for compatibility and it's the same as `View`.
61139

62140
#### Props
63141

64142
* `style?: Style` - Object with [Style properties](#style-properties)
65143

144+
#### Example
145+
146+
```js
147+
class App extends React.Component {
148+
render() {
149+
return (
150+
<View>
151+
<View style={styles.title}>Hello world!</View>
152+
Today is {new Date().toLocaleString()}
153+
</View>
154+
);
155+
}
156+
}
157+
158+
const styles = {
159+
title: {
160+
margin: '0 1',
161+
color: 'green',
162+
},
163+
};
164+
```
165+
66166

67167
### `KeyPress`
68168

example/src/index.js

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
// @TODO: refactor this
2+
13
import React from 'react';
24
// eslint-disable-next-line
3-
import { render, Chunk, Endl, Text } from 'react-stream-renderer';
5+
import { render, Text, makeTTYAdapter } from 'react-stream-renderer';
46

57
import Dev from './dev';
68
import ListViewer from './listViewer';
@@ -20,12 +22,4 @@ switch (process.argv[2]) {
2022
App = ListViewer;
2123
}
2224

23-
render(<App />, process.stdout, {
24-
debug: false,
25-
renderOptimizations: false,
26-
hideCursor: true,
27-
exitOnError: true,
28-
clearOnError: true,
29-
clearScreenOnExit: false,
30-
clearScrollbackOnExit: true,
31-
});
25+
render(<App />, makeTTYAdapter(process.stdout).makeEffects());

src/adapters/BaseAdapter.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/* @flow */
2+
/* eslint-disable class-methods-use-this */
3+
4+
export interface IBaseAdapter {
5+
/**
6+
* Tells if the adapter is ready. For example if there's a need to
7+
* trigger some side effects this flag can be used to distinct if they're
8+
* applied or not.
9+
*/
10+
isReady: boolean;
11+
12+
/**
13+
* Error message to show if the adapter is not ready yet.
14+
*/
15+
notReadyErrorMessage: string;
16+
17+
/**
18+
* Switches to rendering full content of the canvas instead of drawing
19+
* only damaged lines.
20+
*/
21+
forceFullPrint: boolean;
22+
23+
/**
24+
* Provides width and height of the canvas on which the content will be rendered.
25+
*/
26+
getSize(): { width: number, height: number };
27+
28+
/**
29+
* Prints rendered content. This is the place to flush content to the host environment.
30+
*/
31+
print(data: string, metadata: { isFullPrint: boolean }): void;
32+
33+
/**
34+
* Clear the content below the cursor position, which will be set before
35+
* using `setCursorPosition`.
36+
*/
37+
clear(): void;
38+
39+
/**
40+
* Moves cursor to specific coordinates.
41+
*/
42+
setCursorPosition(x: number, y: number): void;
43+
}
44+
45+
type BaseAdapterOptions = {
46+
forceFullPrint: boolean | null | void,
47+
};
48+
49+
export default class BaseAdapter implements IBaseAdapter {
50+
isReady: boolean = false;
51+
notReadyErrorMessage: string;
52+
forceFullPrint: boolean;
53+
54+
constructor({ forceFullPrint }: BaseAdapterOptions = {}) {
55+
this.forceFullPrint = Boolean(forceFullPrint);
56+
}
57+
58+
getSize() {
59+
throw new Error('Adapter#getSize must be provided');
60+
}
61+
62+
// eslint-disable-next-line no-unused-vars
63+
print(data: string, metadata: *) {
64+
throw new Error('Adapter#print must be provided');
65+
}
66+
67+
clear() {}
68+
69+
// eslint-disable-next-line no-unused-vars
70+
setCursorPosition(x: number, y: number) {}
71+
}

src/adapters/TTY/TTYAdapter.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/* @flow */
2+
3+
import readline from 'readline';
4+
5+
import type { NodeStream } from '../../types';
6+
7+
import BaseAdapter from '../BaseAdapter';
8+
import clearCallbacksOnExit from './effects/clearCallbacksOnExit';
9+
import overwriteConsole from './effects/overwriteConsole';
10+
import {
11+
hideCursor,
12+
clearOnExit,
13+
clearScrollbackOnExit,
14+
clearOnError,
15+
} from './effects/terminal';
16+
17+
/**
18+
* Adapter for TTY streams like `process.{stdout,stderr}` with chainable API for
19+
* enhancing the experience with terminal apps.
20+
*
21+
* @example
22+
* const adapter = new TTYAdapter()
23+
* .withCustomConsole({ outStream: true, errStream: true })
24+
* .hideCursor()
25+
* .makeEffects();
26+
*/
27+
export default class TTYAdapter extends BaseAdapter {
28+
notReadyErrorMessage: string = 'Adapter is not ready. Did you forgot to call `makeEffects`?';
29+
effects: Function[];
30+
stream: NodeStream;
31+
32+
constructor({
33+
effects,
34+
stream,
35+
}: {
36+
effects: Function[],
37+
stream: NodeStream,
38+
}) {
39+
super();
40+
this.stream = stream;
41+
this.effects = effects;
42+
}
43+
44+
/**
45+
* Redirect console output to specified streams or files.
46+
* This method won't have any effect unless `makeEffects` is called.
47+
*/
48+
withCustomConsole(params: *) {
49+
return new TTYAdapter({
50+
stream: this.stream,
51+
effects: [...this.effects, () => overwriteConsole(params)],
52+
});
53+
}
54+
55+
/**
56+
* Hides cursor.
57+
* This method won't have any effect unless `makeEffects` is called.
58+
*/
59+
hideCursor() {
60+
return new TTYAdapter({
61+
stream: this.stream,
62+
effects: [...this.effects, hideCursor],
63+
});
64+
}
65+
66+
/**
67+
* Clear screen or scrollback when process is about to exit.
68+
* This method won't have any effect unless `makeEffects` is called.
69+
*/
70+
clearOnExit(shouldClearScrollback: boolean) {
71+
return new TTYAdapter({
72+
stream: this.stream,
73+
effects: [
74+
...this.effects,
75+
adapter => {
76+
if (shouldClearScrollback) {
77+
clearScrollbackOnExit(adapter);
78+
} else {
79+
clearOnExit(adapter);
80+
}
81+
},
82+
],
83+
});
84+
}
85+
86+
/**
87+
* Clear screen when process is about to exit due to an error.
88+
* This method won't have any effect unless `makeEffects` is called.
89+
*/
90+
clearOnError() {
91+
return new TTYAdapter({
92+
stream: this.stream,
93+
effects: [...this.effects, clearOnError],
94+
});
95+
}
96+
97+
/**
98+
* Perform accumulated side effects.
99+
*/
100+
makeEffects() {
101+
clearCallbacksOnExit();
102+
this.effects.forEach(effect => effect(this));
103+
this.isReady = true;
104+
return this;
105+
}
106+
107+
getSize() {
108+
// $FlowFixMe
109+
return { width: this.stream.columns, height: this.stream.rows };
110+
}
111+
112+
print(data: string) {
113+
this.stream.write(data);
114+
}
115+
clear() {
116+
readline.clearScreenDown((this.stream: any));
117+
}
118+
setCursorPosition(x: number, y: number) {
119+
readline.cursorTo((this.stream: any), x, y);
120+
}
121+
}
File renamed without changes.

0 commit comments

Comments
 (0)