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')); +});