From ecdcd44ea249668499174fb77fb75b567c26e498 Mon Sep 17 00:00:00 2001 From: Jonathan Dahan Date: Sat, 7 Sep 2019 16:27:30 -0400 Subject: [PATCH 01/22] add useKeypress hook --- examples/usekeypress/index.js | 2 ++ examples/usekeypress/usekeypress.js | 42 +++++++++++++++++++++++++++++ index.d.ts | 8 ++++++ package.json | 5 +++- src/hooks/useKeypress.js | 15 +++++++++++ src/index.js | 1 + test/exit.js | 42 +++++++++++++++++++++++++++++ test/fixtures/handles-keypresses.js | 22 +++++++++++++++ 8 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 examples/usekeypress/index.js create mode 100644 examples/usekeypress/usekeypress.js create mode 100644 src/hooks/useKeypress.js create mode 100644 test/fixtures/handles-keypresses.js diff --git a/examples/usekeypress/index.js b/examples/usekeypress/index.js new file mode 100644 index 000000000..95364cfde --- /dev/null +++ b/examples/usekeypress/index.js @@ -0,0 +1,2 @@ +'use strict'; +require('import-jsx')('./usekeypress'); diff --git a/examples/usekeypress/usekeypress.js b/examples/usekeypress/usekeypress.js new file mode 100644 index 000000000..836131df2 --- /dev/null +++ b/examples/usekeypress/usekeypress.js @@ -0,0 +1,42 @@ +'use strict'; +const {useState} = require('react'); +const React = require('react'); +const {render, useKeypress, Box} = require('../..'); + +const {exit} = process; + +const Robot = () => { + const [x, setX] = useState(1); + const [y, setY] = useState(1); + + useKeypress(key => { + switch (key) { + case 'h': + setX(Math.max(1, x - 1)); + break; + case 'l': + setX(Math.min(80, x + 1)); + break; + case 'j': + setY(Math.min(40, y + 1)); + break; + case 'k': + setY(Math.max(1, y - 1)); + break; + case 'q': + exit(); + break; + default: + break; + } + }); + + return ( + + Use h, j, k, and l to move the face. q to exit + ^_^ + + ); +}; + +render(); diff --git a/index.d.ts b/index.d.ts index ae762f397..4b5fa5d44 100644 --- a/index.d.ts +++ b/index.d.ts @@ -51,6 +51,14 @@ export type Instance = { export type Unmount = () => void; +/** + * Hook that calls keyPressHandler callback with whatever key has been pressed. + * See key to handle modifiers correctly. + */ +export function useKeypress( + keyPressHandler: (str?: string, key?: Key) => void +): void; + /** * Mount a component and render the output. */ diff --git a/package.json b/package.json index 97952f1be..c68be4d9a 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,10 @@ ], "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/src/hooks/useKeypress.js b/src/hooks/useKeypress.js new file mode 100644 index 000000000..b29fc769d --- /dev/null +++ b/src/hooks/useKeypress.js @@ -0,0 +1,15 @@ +import {useEffect, useContext} from 'react'; +import {StdinContext} from '..'; + +export default keypressHandler => { + const {stdin, setRawMode} = useContext(StdinContext); + + useEffect(() => { + setRawMode(true); + stdin.on('keypress', keypressHandler); + return () => { + stdin.off('keypress', keypressHandler); + setRawMode(false); + }; + }, [stdin, setRawMode, keypressHandler]); +}; diff --git a/src/index.js b/src/index.js index d5357a261..22486bc93 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 useKeypress} from './hooks/useKeypress'; diff --git a/test/exit.js b/test/exit.js index 07381e376..9fe855e7c 100644 --- a/test/exit.js +++ b/test/exit.js @@ -47,6 +47,48 @@ test('exit with thrown error', async t => { t.true(output.includes('errored')); }); +test.cb('handles keypresses', t => { + const term = spawn('node', ['./fixtures/run', './handles-keypresses'], { + name: 'xterm', + cols: 100, + cwd: __dirname, + env: process.env + }); + + let output = ''; + + term.on('data', data => { + output += data; + }); + + let isExited = false; + + term.on('exit', code => { + isExited = true; + + if (code === 0) { + t.true(output.includes('exited')); + t.pass(); + t.end(); + return; + } + + t.fail(); + t.end(); + }); + + setTimeout(() => { + t.false(isExited); + 'abcdefghijklmnopqrstuvwxyz'.split('').forEach(key => term.write(key)); + }, 100); + + setTimeout(() => { + term.kill(); + t.fail(); + t.end(); + }, 5000); +}); + test.cb('don\'t exit while raw mode is active', t => { const term = spawn('node', ['./fixtures/run', './exit-double-raw-mode'], { name: 'xterm-color', diff --git a/test/fixtures/handles-keypresses.js b/test/fixtures/handles-keypresses.js new file mode 100644 index 000000000..567822141 --- /dev/null +++ b/test/fixtures/handles-keypresses.js @@ -0,0 +1,22 @@ +'use strict'; +const React = require('react'); +const {render, useKeypress} = require('../..'); + +const input = new Set('abcdefghijklmnopqrstuvwxyz'.split('')); + +const KeypressTest = () => { + useKeypress(str => { + input.delete(str); + if (input.size === 0) { + app.unmount(); + } + }); + return null; +}; + +const app = render(); + +(async () => { + await app.waitUntilExit(); + console.log('exited'); +})(); From 8aeff95e37d298c8eccd2ef64735dacd2224b780 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Sun, 15 Sep 2019 15:56:57 -0700 Subject: [PATCH 02/22] Add `useInput` hook to handle user input --- examples/useinput/index.js | 2 ++ examples/useinput/useinput.js | 41 +++++++++++++++++++++++ examples/usekeypress/index.js | 2 -- examples/usekeypress/usekeypress.js | 42 ----------------------- index.d.ts | 15 ++++++--- readme.md | 52 +++++++++++++++++++++++++++++ src/components/App.js | 2 -- src/hooks/useInput.js | 30 +++++++++++++++++ src/hooks/useKeypress.js | 15 --------- src/index.js | 2 +- test/exit.js | 42 ----------------------- test/fixtures/handles-keypresses.js | 22 ------------ test/fixtures/handles-user-input.js | 22 ++++++++++++ test/hooks.js | 44 ++++++++++++++++++++++++ 14 files changed, 203 insertions(+), 130 deletions(-) create mode 100644 examples/useinput/index.js create mode 100644 examples/useinput/useinput.js delete mode 100644 examples/usekeypress/index.js delete mode 100644 examples/usekeypress/usekeypress.js create mode 100644 src/hooks/useInput.js delete mode 100644 src/hooks/useKeypress.js delete mode 100644 test/fixtures/handles-keypresses.js create mode 100644 test/fixtures/handles-user-input.js create mode 100644 test/hooks.js 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..b71f615e6 --- /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, meta) => { + if (input === 'q') { + exit(); + } + + if (meta.left) { + setX(Math.max(1, x - 1)); + } + + if (meta.right) { + setX(Math.min(20, x + 1)); + } + + if (meta.up) { + setY(Math.max(1, y - 1)); + } + + if (meta.down) { + setY(Math.min(10, y + 1)); + } + }); + + return ( + + Use arrow keys to move the face. q to exit + ^_^ + + ); +}; + +render(); diff --git a/examples/usekeypress/index.js b/examples/usekeypress/index.js deleted file mode 100644 index 95364cfde..000000000 --- a/examples/usekeypress/index.js +++ /dev/null @@ -1,2 +0,0 @@ -'use strict'; -require('import-jsx')('./usekeypress'); diff --git a/examples/usekeypress/usekeypress.js b/examples/usekeypress/usekeypress.js deleted file mode 100644 index 836131df2..000000000 --- a/examples/usekeypress/usekeypress.js +++ /dev/null @@ -1,42 +0,0 @@ -'use strict'; -const {useState} = require('react'); -const React = require('react'); -const {render, useKeypress, Box} = require('../..'); - -const {exit} = process; - -const Robot = () => { - const [x, setX] = useState(1); - const [y, setY] = useState(1); - - useKeypress(key => { - switch (key) { - case 'h': - setX(Math.max(1, x - 1)); - break; - case 'l': - setX(Math.min(80, x + 1)); - break; - case 'j': - setY(Math.min(40, y + 1)); - break; - case 'k': - setY(Math.max(1, y - 1)); - break; - case 'q': - exit(); - break; - default: - break; - } - }); - - return ( - - Use h, j, k, and l to move the face. q to exit - ^_^ - - ); -}; - -render(); diff --git a/index.d.ts b/index.d.ts index 4b5fa5d44..5c54f0808 100644 --- a/index.d.ts +++ b/index.d.ts @@ -52,13 +52,20 @@ export type Instance = { export type Unmount = () => void; /** - * Hook that calls keyPressHandler callback with whatever key has been pressed. - * See key to handle modifiers correctly. + * Hook that calls inputHandler callback with input that program received. + * Additionally contains helpful metadata for detecting when arrow keys were pressed. */ -export function useKeypress( - keyPressHandler: (str?: string, key?: Key) => void +export function useInput( + inputHandler: (input: string, meta: Meta) => void ): void; +export interface Meta { + up: boolean + down: boolean + left: boolean + right: boolean +}; + /** * Mount a component and render the output. */ diff --git a/readme.md b/readme.md index ed746e11f..2b17fd957 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,57 @@ 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. +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, meta) => { + if (input === 'q') { + // Exit program + } + + if (meta.left) { + // Left arrow key pressed + } + }); + + return … +}; +``` + +Handler function that you pass to `useInput` receives two arguments: + +#### input + +Type: `string` + +Input that program received. + +#### meta + +Type: `object` + +Handy input metadata. Exposes properties to detect if arrow keys were pressed. + +##### meta.left +##### meta.right +##### meta.up +##### meta.down + +Type: `boolean`
+Default: `false` + +If an arrow key was pressed, corresponding property will be `true`. +For example, if user presses left arrow key, `meta.left` equals `true`. + ## 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/useInput.js b/src/hooks/useInput.js new file mode 100644 index 000000000..488452add --- /dev/null +++ b/src/hooks/useInput.js @@ -0,0 +1,30 @@ +import {useLayoutEffect, useContext} from 'react'; +import {StdinContext} from '..'; + +export default inputHandler => { + const {stdin, setRawMode} = useContext(StdinContext); + + useLayoutEffect(() => { + setRawMode(true); + + return () => setRawMode(false); + }, [setRawMode]); + + useLayoutEffect(() => { + const handleData = data => { + const input = String(data); + const meta = { + up: input === '\u001B[A', + down: input === '\u001B[B', + left: input === '\u001B[D', + right: input === '\u001B[C' + }; + + inputHandler(input, meta); + }; + + stdin.on('data', handleData); + + return () => stdin.off('data', handleData); + }, [stdin, inputHandler]); +}; diff --git a/src/hooks/useKeypress.js b/src/hooks/useKeypress.js deleted file mode 100644 index b29fc769d..000000000 --- a/src/hooks/useKeypress.js +++ /dev/null @@ -1,15 +0,0 @@ -import {useEffect, useContext} from 'react'; -import {StdinContext} from '..'; - -export default keypressHandler => { - const {stdin, setRawMode} = useContext(StdinContext); - - useEffect(() => { - setRawMode(true); - stdin.on('keypress', keypressHandler); - return () => { - stdin.off('keypress', keypressHandler); - setRawMode(false); - }; - }, [stdin, setRawMode, keypressHandler]); -}; diff --git a/src/index.js b/src/index.js index 22486bc93..409009032 100644 --- a/src/index.js +++ b/src/index.js @@ -6,4 +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 useKeypress} from './hooks/useKeypress'; +export {default as useInput} from './hooks/useInput'; diff --git a/test/exit.js b/test/exit.js index 9fe855e7c..07381e376 100644 --- a/test/exit.js +++ b/test/exit.js @@ -47,48 +47,6 @@ test('exit with thrown error', async t => { t.true(output.includes('errored')); }); -test.cb('handles keypresses', t => { - const term = spawn('node', ['./fixtures/run', './handles-keypresses'], { - name: 'xterm', - cols: 100, - cwd: __dirname, - env: process.env - }); - - let output = ''; - - term.on('data', data => { - output += data; - }); - - let isExited = false; - - term.on('exit', code => { - isExited = true; - - if (code === 0) { - t.true(output.includes('exited')); - t.pass(); - t.end(); - return; - } - - t.fail(); - t.end(); - }); - - setTimeout(() => { - t.false(isExited); - 'abcdefghijklmnopqrstuvwxyz'.split('').forEach(key => term.write(key)); - }, 100); - - setTimeout(() => { - term.kill(); - t.fail(); - t.end(); - }, 5000); -}); - test.cb('don\'t exit while raw mode is active', t => { const term = spawn('node', ['./fixtures/run', './exit-double-raw-mode'], { name: 'xterm-color', diff --git a/test/fixtures/handles-keypresses.js b/test/fixtures/handles-keypresses.js deleted file mode 100644 index 567822141..000000000 --- a/test/fixtures/handles-keypresses.js +++ /dev/null @@ -1,22 +0,0 @@ -'use strict'; -const React = require('react'); -const {render, useKeypress} = require('../..'); - -const input = new Set('abcdefghijklmnopqrstuvwxyz'.split('')); - -const KeypressTest = () => { - useKeypress(str => { - input.delete(str); - if (input.size === 0) { - app.unmount(); - } - }); - return null; -}; - -const app = render(); - -(async () => { - await app.waitUntilExit(); - console.log('exited'); -})(); diff --git a/test/fixtures/handles-user-input.js b/test/fixtures/handles-user-input.js new file mode 100644 index 000000000..08a9e3498 --- /dev/null +++ b/test/fixtures/handles-user-input.js @@ -0,0 +1,22 @@ +'use strict'; +const React = require('react'); +const {render, useInput, AppContext} = require('../..'); + +const UserInput = () => { + const {exit} = React.useContext(AppContext); + + useInput(input => { + if (input === 'q') { + exit(); + } + }); + + 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..94232ef6f --- /dev/null +++ b/test/hooks.js @@ -0,0 +1,44 @@ +import {serial as test} from 'ava'; +import {spawn} from 'node-pty'; + +test.cb('exit when user types "q" character', t => { + const term = spawn('node', ['./fixtures/run', './handles-user-input'], { + name: 'xterm-color', + cols: 100, + cwd: __dirname, + env: process.env + }); + + let output = ''; + + term.on('data', data => { + output += data; + }); + + let isExited = false; + + term.on('exit', code => { + isExited = true; + + if (code === 0) { + t.true(output.includes('exited')); + t.pass(); + t.end(); + return; + } + + t.fail(); + t.end(); + }); + + setTimeout(() => { + t.false(isExited); + term.write('q'); + }, 1000); + + setTimeout(() => { + term.kill(); + t.fail(); + t.end(); + }, 1500); +}); From 7dffe973618e99d82b2c8f6390b65bd60e49fc4a Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Sun, 15 Sep 2019 16:06:32 -0700 Subject: [PATCH 03/22] Minor changes --- examples/useinput/useinput.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/useinput/useinput.js b/examples/useinput/useinput.js index b71f615e6..f77616cbf 100644 --- a/examples/useinput/useinput.js +++ b/examples/useinput/useinput.js @@ -32,7 +32,7 @@ const Robot = () => { return ( - Use arrow keys to move the face. q to exit + Use arrow keys to move the face. Press "q" to exit. ^_^ ); From 177fde02eaa4e47e9c1522d5c96389c77e2feb0b Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Sun, 15 Sep 2019 16:12:32 -0700 Subject: [PATCH 04/22] Minor fixes --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index c68be4d9a..242eec5e7 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,9 @@ "plugins": [ "react" ], + "rules": { + "react/no-unescaped-entities": "off" + }, "overrides": [ { "files": [ From 593a12c8e7ae871e53cd3319ba3eb3c8be227d8c Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 20:31:34 -0700 Subject: [PATCH 05/22] Update src/hooks/useInput.js Co-Authored-By: Sindre Sorhus --- src/hooks/useInput.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useInput.js b/src/hooks/useInput.js index 488452add..f45fcc965 100644 --- a/src/hooks/useInput.js +++ b/src/hooks/useInput.js @@ -7,7 +7,9 @@ export default inputHandler => { useLayoutEffect(() => { setRawMode(true); - return () => setRawMode(false); + return () => { + setRawMode(false); + }; }, [setRawMode]); useLayoutEffect(() => { From ab2032c26386aecbd6f7795cb1c51ca1c0d14fb7 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 20:31:47 -0700 Subject: [PATCH 06/22] Update src/hooks/useInput.js Co-Authored-By: Sindre Sorhus --- src/hooks/useInput.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useInput.js b/src/hooks/useInput.js index f45fcc965..93c97cab2 100644 --- a/src/hooks/useInput.js +++ b/src/hooks/useInput.js @@ -27,6 +27,8 @@ export default inputHandler => { stdin.on('data', handleData); - return () => stdin.off('data', handleData); + return () => { + stdin.off('data', handleData); + }; }, [stdin, inputHandler]); }; From 717aca4da5e600a4c777c2ee4e99765ca9565aa2 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 20:32:12 -0700 Subject: [PATCH 07/22] Update examples/useinput/useinput.js Co-Authored-By: Sindre Sorhus --- examples/useinput/useinput.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/useinput/useinput.js b/examples/useinput/useinput.js index f77616cbf..667590343 100644 --- a/examples/useinput/useinput.js +++ b/examples/useinput/useinput.js @@ -32,7 +32,7 @@ const Robot = () => { return ( - Use arrow keys to move the face. Press "q" to exit. + Use arrow keys to move the face. Press “q” to exit. ^_^ ); From 6512f0a9e3e923f10b0bfd265ca16d2a44b33efe Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 20:33:08 -0700 Subject: [PATCH 08/22] Apply suggestions from code review Co-Authored-By: Sindre Sorhus --- index.d.ts | 2 +- readme.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/index.d.ts b/index.d.ts index 5c54f0808..f27657993 100644 --- a/index.d.ts +++ b/index.d.ts @@ -52,7 +52,7 @@ export type Instance = { export type Unmount = () => void; /** - * Hook that calls inputHandler callback with input that program received. + * Hook that calls the `inputHandler` callback with the input that the program received. * Additionally contains helpful metadata for detecting when arrow keys were pressed. */ export function useInput( diff --git a/readme.md b/readme.md index 2b17fd957..5e2e980ce 100644 --- a/readme.md +++ b/readme.md @@ -873,13 +873,13 @@ const UserInput = () => { }; ``` -Handler function that you pass to `useInput` receives two arguments: +The handler function that you pass to `useInput` receives two arguments: #### input Type: `string` -Input that program received. +The input that the program received. #### meta @@ -895,7 +895,7 @@ Handy input metadata. Exposes properties to detect if arrow keys were pressed. Type: `boolean`
Default: `false` -If an arrow key was pressed, corresponding property will be `true`. +If an arrow key was pressed, the corresponding property will be `true`. For example, if user presses left arrow key, `meta.left` equals `true`. From 3fa0cfcfb7895984a0818e783239aa46f6e72426 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 20:41:52 -0700 Subject: [PATCH 09/22] Fix feedback --- index.d.ts | 14 +++++++------- readme.md | 2 ++ src/hooks/useInput.js | 12 ++++++------ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/index.d.ts b/index.d.ts index f27657993..f5de240f5 100644 --- a/index.d.ts +++ b/index.d.ts @@ -53,17 +53,17 @@ export type Unmount = () => void; /** * Hook that calls the `inputHandler` callback with the input that the program received. - * Additionally contains helpful metadata for detecting when arrow keys were pressed. + * Additionally contains helpful metadata about the key that was pressed. */ export function useInput( - inputHandler: (input: string, meta: Meta) => void + inputHandler: (input: string, key: Key) => void ): void; -export interface Meta { - up: boolean - down: boolean - left: boolean - right: boolean +export interface Key { + upArrow: boolean + downArrow: boolean + leftArrow: boolean + rightArrow: boolean }; /** diff --git a/readme.md b/readme.md index 5e2e980ce..9051646d5 100644 --- a/readme.md +++ b/readme.md @@ -853,6 +853,8 @@ Usage: 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 diff --git a/src/hooks/useInput.js b/src/hooks/useInput.js index 93c97cab2..74a9d2022 100644 --- a/src/hooks/useInput.js +++ b/src/hooks/useInput.js @@ -15,14 +15,14 @@ export default inputHandler => { useLayoutEffect(() => { const handleData = data => { const input = String(data); - const meta = { - up: input === '\u001B[A', - down: input === '\u001B[B', - left: input === '\u001B[D', - right: input === '\u001B[C' + const key = { + upArrow: input === '\u001B[A', + downArrow: input === '\u001B[B', + leftArrow: input === '\u001B[D', + rightArrow: input === '\u001B[C' }; - inputHandler(input, meta); + inputHandler(input, key); }; stdin.on('data', handleData); From ec82eff22c45480bfbd17265312dfe0e6a50e9f3 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 20:50:30 -0700 Subject: [PATCH 10/22] Missed docs in readme --- readme.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/readme.md b/readme.md index 9051646d5..f4d527432 100644 --- a/readme.md +++ b/readme.md @@ -861,12 +861,12 @@ You can find a full example of using `useInput` at [examples/useinput](examples/ import {useInput} from 'ink'; const UserInput = () => { - useInput((input, meta) => { + useInput((input, key) => { if (input === 'q') { // Exit program } - if (meta.left) { + if (key.leftArrow) { // Left arrow key pressed } }); @@ -883,22 +883,22 @@ Type: `string` The input that the program received. -#### meta +#### key Type: `object` -Handy input metadata. Exposes properties to detect if arrow keys were pressed. +Handy information about a key that was pressed. -##### meta.left -##### meta.right -##### meta.up -##### meta.down +##### 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, `meta.left` equals `true`. +For example, if user presses left arrow key, `key.leftArrow` equals `true`. ## Useful Components From 7d7b7cfdecb6dab81c14cd50ceeb60482699f43e Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 21:08:52 -0700 Subject: [PATCH 11/22] Add more key modifiers --- examples/useinput/useinput.js | 10 +++++----- index.d.ts | 26 ++++++++++++++++++++++++-- readme.md | 20 ++++++++++++++++++++ src/hooks/useInput.js | 18 ++++++++++++++++-- 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/examples/useinput/useinput.js b/examples/useinput/useinput.js index 667590343..d779e58fd 100644 --- a/examples/useinput/useinput.js +++ b/examples/useinput/useinput.js @@ -8,24 +8,24 @@ const Robot = () => { const [x, setX] = useState(1); const [y, setY] = useState(1); - useInput((input, meta) => { + useInput((input, key) => { if (input === 'q') { exit(); } - if (meta.left) { + if (key.leftArrow) { setX(Math.max(1, x - 1)); } - if (meta.right) { + if (key.rightArrow) { setX(Math.min(20, x + 1)); } - if (meta.up) { + if (key.upArrow) { setY(Math.max(1, y - 1)); } - if (meta.down) { + if (key.downArrow) { setY(Math.min(10, y + 1)); } }); diff --git a/index.d.ts b/index.d.ts index f5de240f5..983ed3849 100644 --- a/index.d.ts +++ b/index.d.ts @@ -52,8 +52,26 @@ export type Instance = { export type Unmount = () => void; /** - * Hook that calls the `inputHandler` callback with the input that the program received. - * Additionally contains helpful metadata about the key that was pressed. + * 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 @@ -64,6 +82,10 @@ export interface Key { downArrow: boolean leftArrow: boolean rightArrow: boolean + return: boolean + escape: boolean + ctrl: boolean + shift: boolean }; /** diff --git a/readme.md b/readme.md index f4d527432..75ba846df 100644 --- a/readme.md +++ b/readme.md @@ -900,6 +900,26 @@ 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. ## Useful Components diff --git a/src/hooks/useInput.js b/src/hooks/useInput.js index 74a9d2022..971877d97 100644 --- a/src/hooks/useInput.js +++ b/src/hooks/useInput.js @@ -14,14 +14,28 @@ export default inputHandler => { useLayoutEffect(() => { const handleData = data => { - const input = String(data); + let input = String(data); const key = { upArrow: input === '\u001B[A', downArrow: input === '\u001B[B', leftArrow: input === '\u001B[D', - rightArrow: input === '\u001B[C' + rightArrow: input === '\u001B[C', + return: input === '\r', + escape: input === '\x1b', + ctrl: false, + shift: false }; + // Copied from `keypress` module + if (input <= '\x1a') { + input = String.fromCharCode(input.charCodeAt(0) + 'a'.charCodeAt(0) - 1); + key.ctrl = true; + } + + if (input.length === 1 && input >= 'A' && input <= 'Z') { + key.shift = true; + } + inputHandler(input, key); }; From f770975778be82088b64ed4ea641863dde96271a Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 21:12:20 -0700 Subject: [PATCH 12/22] Add support for cyrillic uppercase --- src/hooks/useInput.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useInput.js b/src/hooks/useInput.js index 971877d97..2d8cc1f59 100644 --- a/src/hooks/useInput.js +++ b/src/hooks/useInput.js @@ -32,7 +32,9 @@ export default inputHandler => { key.ctrl = true; } - if (input.length === 1 && input >= 'A' && input <= 'Z') { + const isLatinUppercase = input >= 'A' && input <= 'Z' + const isCyrillicUppercase = input => 'А' && input <= 'Я' + if (input.length === 1 && (isLatinUppercase || isCyrillicUppercase)) { key.shift = true; } From 247da6248254723b5141816c3184ec6e7a7b83b1 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 21:17:17 -0700 Subject: [PATCH 13/22] Fixes --- src/hooks/useInput.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/hooks/useInput.js b/src/hooks/useInput.js index 2d8cc1f59..305ae6a87 100644 --- a/src/hooks/useInput.js +++ b/src/hooks/useInput.js @@ -21,21 +21,21 @@ export default inputHandler => { leftArrow: input === '\u001B[D', rightArrow: input === '\u001B[C', return: input === '\r', - escape: input === '\x1b', + escape: input === '\u001B', ctrl: false, shift: false }; // Copied from `keypress` module - if (input <= '\x1a') { - input = String.fromCharCode(input.charCodeAt(0) + 'a'.charCodeAt(0) - 1); - key.ctrl = true; - } + if (input <= '\u001A') { + input = String.fromCharCode(input.charCodeAt(0) + 'a'.charCodeAt(0) - 1); + key.ctrl = true; + } - const isLatinUppercase = input >= 'A' && input <= 'Z' - const isCyrillicUppercase = input => 'А' && input <= 'Я' + const isLatinUppercase = input >= 'A' && input <= 'Z'; + const isCyrillicUppercase = input >= 'А' && input <= 'Я'; if (input.length === 1 && (isLatinUppercase || isCyrillicUppercase)) { - key.shift = true; + key.shift = true; } inputHandler(input, key); From d6d464cda1eca9f94ceab754d17312a4ddef5c9f Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 21:22:59 -0700 Subject: [PATCH 14/22] Rename useInput -> use-input --- src/hooks/{useInput.js => use-input.js} | 0 src/index.js | 2 +- test/fixtures/{handles-user-input.js => use-input.js} | 0 test/hooks.js | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) rename src/hooks/{useInput.js => use-input.js} (100%) rename test/fixtures/{handles-user-input.js => use-input.js} (100%) diff --git a/src/hooks/useInput.js b/src/hooks/use-input.js similarity index 100% rename from src/hooks/useInput.js rename to src/hooks/use-input.js diff --git a/src/index.js b/src/index.js index 409009032..54f906659 100644 --- a/src/index.js +++ b/src/index.js @@ -6,4 +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/useInput'; +export {default as useInput} from './hooks/use-input'; diff --git a/test/fixtures/handles-user-input.js b/test/fixtures/use-input.js similarity index 100% rename from test/fixtures/handles-user-input.js rename to test/fixtures/use-input.js diff --git a/test/hooks.js b/test/hooks.js index 94232ef6f..900457467 100644 --- a/test/hooks.js +++ b/test/hooks.js @@ -2,7 +2,7 @@ import {serial as test} from 'ava'; import {spawn} from 'node-pty'; test.cb('exit when user types "q" character', t => { - const term = spawn('node', ['./fixtures/run', './handles-user-input'], { + const term = spawn('node', ['./fixtures/run', './use-input'], { name: 'xterm-color', cols: 100, cwd: __dirname, From d9870e0b7d21d19d957b469dbb32b09454f41b2b Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 21:55:22 -0700 Subject: [PATCH 15/22] Fixes --- src/hooks/use-input.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/use-input.js b/src/hooks/use-input.js index 305ae6a87..7ee5e27aa 100644 --- a/src/hooks/use-input.js +++ b/src/hooks/use-input.js @@ -27,7 +27,7 @@ export default inputHandler => { }; // Copied from `keypress` module - if (input <= '\u001A') { + if (input <= '\u001A' && !key.return) { input = String.fromCharCode(input.charCodeAt(0) + 'a'.charCodeAt(0) - 1); key.ctrl = true; } From b41bd6e8430cb3b3d836f83b6199e7b6808b21dd Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Fri, 20 Sep 2019 21:55:26 -0700 Subject: [PATCH 16/22] Add tests --- test/fixtures/use-input.js | 46 +++++++++++++++-- test/hooks.js | 101 ++++++++++++++++++++++++++++--------- 2 files changed, 119 insertions(+), 28 deletions(-) diff --git a/test/fixtures/use-input.js b/test/fixtures/use-input.js index 08a9e3498..82618d2b7 100644 --- a/test/fixtures/use-input.js +++ b/test/fixtures/use-input.js @@ -2,19 +2,57 @@ const React = require('react'); const {render, useInput, AppContext} = require('../..'); -const UserInput = () => { +const UserInput = ({test}) => { const {exit} = React.useContext(AppContext); - useInput(input => { - if (input === 'q') { + 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 === '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(); +const app = render(); (async () => { await app.waitUntilExit(); diff --git a/test/hooks.js b/test/hooks.js index 900457467..0bc1105d9 100644 --- a/test/hooks.js +++ b/test/hooks.js @@ -1,44 +1,97 @@ import {serial as test} from 'ava'; import {spawn} from 'node-pty'; -test.cb('exit when user types "q" character', t => { - const term = spawn('node', ['./fixtures/run', './use-input'], { +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 }); - let output = ''; + const result = { + write: input => ps.write(input), + output: '', + waitForExit: () => exitPromise + }; - term.on('data', data => { - output += data; + ps.on('data', data => { + result.output += data; }); - let isExited = false; - - term.on('exit', code => { - isExited = true; - + ps.on('exit', code => { if (code === 0) { - t.true(output.includes('exited')); - t.pass(); - t.end(); + resolve(); return; } - t.fail(); - t.end(); + reject(new Error(`Process exited with non-zero exit code: ${code}`)); }); - setTimeout(() => { - t.false(isExited); - term.write('q'); - }, 1000); + 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 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')); +}); - setTimeout(() => { - term.kill(); - t.fail(); - t.end(); - }, 1500); +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')); }); From 1bdd495e87053d1a62ef48554958b0b335957524 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Sun, 22 Sep 2019 13:48:48 -0700 Subject: [PATCH 17/22] Minor fixes --- index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index 983ed3849..9be2cb59f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -86,7 +86,7 @@ export interface Key { escape: boolean ctrl: boolean shift: boolean -}; +} /** * Mount a component and render the output. From 468e33baf784b3c93b61f7e4dcfe70a011026308 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Sun, 22 Sep 2019 14:09:48 -0700 Subject: [PATCH 18/22] Add meta key --- index.d.ts | 1 + readme.md | 7 +++++++ src/hooks/use-input.js | 8 +++++++- test/fixtures/use-input.js | 5 +++++ test/hooks.js | 7 +++++++ 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index 9be2cb59f..f1599d84a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -86,6 +86,7 @@ export interface Key { escape: boolean ctrl: boolean shift: boolean + meta: boolean } /** diff --git a/readme.md b/readme.md index 75ba846df..c8caa2a5f 100644 --- a/readme.md +++ b/readme.md @@ -919,6 +919,13 @@ Ctrl key was pressed. Type: `boolean`
Default: `false` +##### key.meta + +Type: `boolean`
+Default: `false` + +[Meta key](https://en.wikipedia.org/wiki/Meta_key) was pressed. + Shift key was pressed. ## Useful Components diff --git a/src/hooks/use-input.js b/src/hooks/use-input.js index 7ee5e27aa..5620b0af8 100644 --- a/src/hooks/use-input.js +++ b/src/hooks/use-input.js @@ -23,7 +23,8 @@ export default inputHandler => { return: input === '\r', escape: input === '\u001B', ctrl: false, - shift: false + shift: false, + meta: false }; // Copied from `keypress` module @@ -32,6 +33,11 @@ export default inputHandler => { 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)) { diff --git a/test/fixtures/use-input.js b/test/fixtures/use-input.js index 82618d2b7..32d174316 100644 --- a/test/fixtures/use-input.js +++ b/test/fixtures/use-input.js @@ -26,6 +26,11 @@ const UserInput = ({test}) => { return; } + if (test === 'meta' && input === 'm' && key.meta) { + exit(); + return; + } + if (test === 'upArrow' && key.upArrow) { exit(); return; diff --git a/test/hooks.js b/test/hooks.js index 0bc1105d9..1ea1b8fcf 100644 --- a/test/hooks.js +++ b/test/hooks.js @@ -68,6 +68,13 @@ test('handle ctrl', async t => { 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'); From 8abcd6817ec0d2552a1b3f9a2f0e645ee50246bb Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Sun, 22 Sep 2019 15:52:41 -0700 Subject: [PATCH 19/22] Minor changes --- index.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/index.d.ts b/index.d.ts index f1599d84a..a077cc4b8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -57,6 +57,7 @@ export type Unmount = () => void; * 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`. * + * ```js * import {useInput} from 'ink'; * * const UserInput = () => { @@ -72,6 +73,7 @@ export type Unmount = () => void; * * return … * }; + * ``` */ export function useInput( inputHandler: (input: string, key: Key) => void From 44e9fa59366b14da67a30274f018e495859cd8b7 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Mon, 23 Sep 2019 00:06:23 -0700 Subject: [PATCH 20/22] Switch useLayoutEffect with useEffect --- src/hooks/use-input.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/use-input.js b/src/hooks/use-input.js index 5620b0af8..913c7b350 100644 --- a/src/hooks/use-input.js +++ b/src/hooks/use-input.js @@ -1,10 +1,10 @@ -import {useLayoutEffect, useContext} from 'react'; +import {useEffect, useContext} from 'react'; import {StdinContext} from '..'; export default inputHandler => { const {stdin, setRawMode} = useContext(StdinContext); - useLayoutEffect(() => { + useEffect(() => { setRawMode(true); return () => { @@ -12,7 +12,7 @@ export default inputHandler => { }; }, [setRawMode]); - useLayoutEffect(() => { + useEffect(() => { const handleData = data => { let input = String(data); const key = { From 8b073b4cee0e84ec32641af5f00300e2c85d05af Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Wed, 25 Sep 2019 20:43:20 -0700 Subject: [PATCH 21/22] Update index.d.ts Co-Authored-By: Sindre Sorhus --- index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index a077cc4b8..cb2a60387 100644 --- a/index.d.ts +++ b/index.d.ts @@ -57,7 +57,7 @@ export type Unmount = () => void; * 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`. * - * ```js + * ``` * import {useInput} from 'ink'; * * const UserInput = () => { From 07c785e50bfe4cfdbfdc5427e60ff24ea54ec1f7 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Wed, 25 Sep 2019 20:44:30 -0700 Subject: [PATCH 22/22] Minor fixes --- readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index c8caa2a5f..71425e498 100644 --- a/readme.md +++ b/readme.md @@ -919,6 +919,8 @@ Ctrl key was pressed. Type: `boolean`
Default: `false` +Shift key was pressed. + ##### key.meta Type: `boolean`
@@ -926,7 +928,6 @@ Default: `false` [Meta key](https://en.wikipedia.org/wiki/Meta_key) was pressed. -Shift key was pressed. ## Useful Components