diff --git a/examples/useinput/index.js b/examples/useinput/index.js
new file mode 100644
index 000000000..6a72c53fa
--- /dev/null
+++ b/examples/useinput/index.js
@@ -0,0 +1,2 @@
+'use strict';
+require('import-jsx')('./useinput');
diff --git a/examples/useinput/useinput.js b/examples/useinput/useinput.js
new file mode 100644
index 000000000..d779e58fd
--- /dev/null
+++ b/examples/useinput/useinput.js
@@ -0,0 +1,41 @@
+'use strict';
+const {useState, useContext} = require('react');
+const React = require('react');
+const {render, useInput, Box, AppContext} = require('../..');
+
+const Robot = () => {
+ const {exit} = useContext(AppContext);
+ const [x, setX] = useState(1);
+ const [y, setY] = useState(1);
+
+ useInput((input, key) => {
+ if (input === 'q') {
+ exit();
+ }
+
+ if (key.leftArrow) {
+ setX(Math.max(1, x - 1));
+ }
+
+ if (key.rightArrow) {
+ setX(Math.min(20, x + 1));
+ }
+
+ if (key.upArrow) {
+ setY(Math.max(1, y - 1));
+ }
+
+ if (key.downArrow) {
+ setY(Math.min(10, y + 1));
+ }
+ });
+
+ return (
+
+ Use arrow keys to move the face. Press “q” to exit.
+ ^_^
+
+ );
+};
+
+render();
diff --git a/index.d.ts b/index.d.ts
index ae762f397..cb2a60387 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -51,6 +51,46 @@ export type Instance = {
export type Unmount = () => void;
+/**
+ * This hook is used for handling user input.
+ * It's a more convienient alternative to using `StdinContext` and listening to `data` events.
+ * The callback you pass to `useInput` is called for each character when user enters any input.
+ * 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`.
+ *
+ * ```
+ * import {useInput} from 'ink';
+ *
+ * const UserInput = () => {
+ * useInput((input, key) => {
+ * if (input === 'q') {
+ * // Exit program
+ * }
+ *
+ * if (key.leftArrow) {
+ * // Left arrow key pressed
+ * }
+ * });
+ *
+ * return …
+ * };
+ * ```
+ */
+export function useInput(
+ inputHandler: (input: string, key: Key) => void
+): void;
+
+export interface Key {
+ upArrow: boolean
+ downArrow: boolean
+ leftArrow: boolean
+ rightArrow: boolean
+ return: boolean
+ escape: boolean
+ ctrl: boolean
+ shift: boolean
+ meta: boolean
+}
+
/**
* Mount a component and render the output.
*/
diff --git a/package.json b/package.json
index 97952f1be..242eec5e7 100644
--- a/package.json
+++ b/package.json
@@ -117,9 +117,15 @@
"plugins": [
"react"
],
+ "rules": {
+ "react/no-unescaped-entities": "off"
+ },
"overrides": [
{
- "files": "src/components/*.js",
+ "files": [
+ "src/components/*.js",
+ "src/hooks/*.js"
+ ],
"rules": {
"unicorn/filename-case": "off",
"react/require-default-props": "warning"
diff --git a/readme.md b/readme.md
index ed746e11f..71425e498 100644
--- a/readme.md
+++ b/readme.md
@@ -85,6 +85,7 @@ Feel free to play around with the code and fork this repl at [https://repl.it/@v
- [API](#api)
- [Building Layouts](#building-layouts)
- [Built-in Components](#built-in-components)
+- [Hooks](#hooks)
- [Useful Components](#useful-components)
- [Testing](#testing)
- [Experimental mode](#experimental-mode)
@@ -846,6 +847,87 @@ Usage:
```
+## Hooks
+
+### useInput
+
+This hook is used for handling user input.
+It's a more convienient alternative to using `StdinContext` and listening to `data` events.
+The callback you pass to `useInput` is called for each character when user enters any input.
+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`.
+You can find a full example of using `useInput` at [examples/useinput](examples/useinput/useinput.js).
+
+```jsx
+import {useInput} from 'ink';
+
+const UserInput = () => {
+ useInput((input, key) => {
+ if (input === 'q') {
+ // Exit program
+ }
+
+ if (key.leftArrow) {
+ // Left arrow key pressed
+ }
+ });
+
+ return …
+};
+```
+
+The handler function that you pass to `useInput` receives two arguments:
+
+#### input
+
+Type: `string`
+
+The input that the program received.
+
+#### key
+
+Type: `object`
+
+Handy information about a key that was pressed.
+
+##### key.leftArrow
+##### key.rightArrow
+##### key.upArrow
+##### key.downArrow
+
+Type: `boolean`
+Default: `false`
+
+If an arrow key was pressed, the corresponding property will be `true`.
+For example, if user presses left arrow key, `key.leftArrow` equals `true`.
+
+##### key.return
+
+Type: `boolean`
+Default: `false`
+
+Return (Enter) key was pressed.
+
+##### key.ctrl
+
+Type: `boolean`
+Default: `false`
+
+Ctrl key was pressed.
+
+##### key.shift
+
+Type: `boolean`
+Default: `false`
+
+Shift key was pressed.
+
+##### key.meta
+
+Type: `boolean`
+Default: `false`
+
+[Meta key](https://en.wikipedia.org/wiki/Meta_key) was pressed.
+
## Useful Components
diff --git a/src/components/App.js b/src/components/App.js
index 6bec457a5..aca09a9c1 100644
--- a/src/components/App.js
+++ b/src/components/App.js
@@ -1,4 +1,3 @@
-import readline from 'readline';
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import cliCursor from 'cli-cursor';
@@ -97,7 +96,6 @@ export default class App extends PureComponent {
stdin.addListener('data', this.handleInput);
stdin.resume();
stdin.setRawMode(true);
- readline.emitKeypressEvents(stdin);
}
this.rawModeEnabledCount++;
diff --git a/src/hooks/use-input.js b/src/hooks/use-input.js
new file mode 100644
index 000000000..913c7b350
--- /dev/null
+++ b/src/hooks/use-input.js
@@ -0,0 +1,56 @@
+import {useEffect, useContext} from 'react';
+import {StdinContext} from '..';
+
+export default inputHandler => {
+ const {stdin, setRawMode} = useContext(StdinContext);
+
+ useEffect(() => {
+ setRawMode(true);
+
+ return () => {
+ setRawMode(false);
+ };
+ }, [setRawMode]);
+
+ useEffect(() => {
+ const handleData = data => {
+ let input = String(data);
+ const key = {
+ upArrow: input === '\u001B[A',
+ downArrow: input === '\u001B[B',
+ leftArrow: input === '\u001B[D',
+ rightArrow: input === '\u001B[C',
+ return: input === '\r',
+ escape: input === '\u001B',
+ ctrl: false,
+ shift: false,
+ meta: false
+ };
+
+ // Copied from `keypress` module
+ if (input <= '\u001A' && !key.return) {
+ input = String.fromCharCode(input.charCodeAt(0) + 'a'.charCodeAt(0) - 1);
+ key.ctrl = true;
+ }
+
+ if (input[0] === '\u001B') {
+ input = input.slice(1);
+ key.meta = true;
+ }
+
+ const isLatinUppercase = input >= 'A' && input <= 'Z';
+ const isCyrillicUppercase = input >= 'А' && input <= 'Я';
+ if (input.length === 1 && (isLatinUppercase || isCyrillicUppercase)) {
+ key.shift = true;
+ }
+
+ inputHandler(input, key);
+ };
+
+ stdin.on('data', handleData);
+
+ return () => {
+ stdin.off('data', handleData);
+ };
+ }, [stdin, inputHandler]);
+};
diff --git a/src/index.js b/src/index.js
index d5357a261..54f906659 100644
--- a/src/index.js
+++ b/src/index.js
@@ -6,3 +6,4 @@ export {default as AppContext} from './components/AppContext';
export {default as StdinContext} from './components/StdinContext';
export {default as StdoutContext} from './components/StdoutContext';
export {default as Static} from './components/Static';
+export {default as useInput} from './hooks/use-input';
diff --git a/test/fixtures/use-input.js b/test/fixtures/use-input.js
new file mode 100644
index 000000000..32d174316
--- /dev/null
+++ b/test/fixtures/use-input.js
@@ -0,0 +1,65 @@
+'use strict';
+const React = require('react');
+const {render, useInput, AppContext} = require('../..');
+
+const UserInput = ({test}) => {
+ const {exit} = React.useContext(AppContext);
+
+ useInput((input, key) => {
+ if (test === 'lowercase' && input === 'q') {
+ exit();
+ return;
+ }
+
+ if (test === 'uppercase' && input === 'Q' && key.shift) {
+ exit();
+ return;
+ }
+
+ if (test === 'escape' && key.escape) {
+ exit();
+ return;
+ }
+
+ if (test === 'ctrl' && input === 'f' && key.ctrl) {
+ exit();
+ return;
+ }
+
+ if (test === 'meta' && input === 'm' && key.meta) {
+ exit();
+ return;
+ }
+
+ if (test === 'upArrow' && key.upArrow) {
+ exit();
+ return;
+ }
+
+ if (test === 'downArrow' && key.downArrow) {
+ exit();
+ return;
+ }
+
+ if (test === 'leftArrow' && key.leftArrow) {
+ exit();
+ return;
+ }
+
+ if (test === 'rightArrow' && key.rightArrow) {
+ exit();
+ return;
+ }
+
+ throw new Error('Crash');
+ });
+
+ return null;
+};
+
+const app = render();
+
+(async () => {
+ await app.waitUntilExit();
+ console.log('exited');
+})();
diff --git a/test/hooks.js b/test/hooks.js
new file mode 100644
index 000000000..1ea1b8fcf
--- /dev/null
+++ b/test/hooks.js
@@ -0,0 +1,104 @@
+import {serial as test} from 'ava';
+import {spawn} from 'node-pty';
+
+const term = (fixture, args = []) => {
+ let resolve;
+ let reject;
+
+ // eslint-disable-next-line promise/param-names
+ const exitPromise = new Promise((resolve2, reject2) => {
+ resolve = resolve2;
+ reject = reject2;
+ });
+
+ const ps = spawn('node', ['./fixtures/run', `./${fixture}`, ...args], {
+ name: 'xterm-color',
+ cols: 100,
+ cwd: __dirname,
+ env: process.env
+ });
+
+ const result = {
+ write: input => ps.write(input),
+ output: '',
+ waitForExit: () => exitPromise
+ };
+
+ ps.on('data', data => {
+ result.output += data;
+ });
+
+ ps.on('exit', code => {
+ if (code === 0) {
+ resolve();
+ return;
+ }
+
+ reject(new Error(`Process exited with non-zero exit code: ${code}`));
+ });
+
+ return result;
+};
+
+test('handle lowercase character', async t => {
+ const ps = term('use-input', ['lowercase']);
+ ps.write('q');
+ await ps.waitForExit();
+ t.true(ps.output.includes('exited'));
+});
+
+test('handle uppercase character', async t => {
+ const ps = term('use-input', ['uppercase']);
+ ps.write('Q');
+ await ps.waitForExit();
+ t.true(ps.output.includes('exited'));
+});
+
+test('handle escape', async t => {
+ const ps = term('use-input', ['escape']);
+ ps.write('\u001B');
+ await ps.waitForExit();
+ t.true(ps.output.includes('exited'));
+});
+
+test('handle ctrl', async t => {
+ const ps = term('use-input', ['ctrl']);
+ ps.write('\u0006');
+ await ps.waitForExit();
+ t.true(ps.output.includes('exited'));
+});
+
+test('handle meta', async t => {
+ const ps = term('use-input', ['meta']);
+ ps.write('\u001Bm');
+ await ps.waitForExit();
+ t.true(ps.output.includes('exited'));
+});
+
+test('handle up arrow', async t => {
+ const ps = term('use-input', ['upArrow']);
+ ps.write('\u001B[A');
+ await ps.waitForExit();
+ t.true(ps.output.includes('exited'));
+});
+
+test('handle down arrow', async t => {
+ const ps = term('use-input', ['downArrow']);
+ ps.write('\u001B[B');
+ await ps.waitForExit();
+ t.true(ps.output.includes('exited'));
+});
+
+test('handle left arrow', async t => {
+ const ps = term('use-input', ['leftArrow']);
+ ps.write('\u001B[D');
+ await ps.waitForExit();
+ t.true(ps.output.includes('exited'));
+});
+
+test('handle right arrow', async t => {
+ const ps = term('use-input', ['rightArrow']);
+ ps.write('\u001B[C');
+ await ps.waitForExit();
+ t.true(ps.output.includes('exited'));
+});