Skip to content

Commit

Permalink
Rework parsing keyboard input in useInput (#576)
Browse files Browse the repository at this point in the history
  • Loading branch information
vadimdemedes committed Apr 1, 2023
1 parent 8180c1c commit 6f99ca3
Show file tree
Hide file tree
Showing 5 changed files with 331 additions and 41 deletions.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
"code-excerpt": "^4.0.0",
"indent-string": "^5.0.0",
"is-ci": "^3.0.1",
"is-lower-case": "^2.0.2",
"is-upper-case": "^2.0.2",
"lodash": "^4.17.21",
"patch-console": "^2.0.0",
"react-reconciler": "^0.29.0",
Expand Down Expand Up @@ -148,6 +150,9 @@
"react-hooks/exhaustive-deps": "off",
"complexity": "off"
},
"ignores": [
"src/parse-keypress.ts"
],
"overrides": [
{
"files": [
Expand Down
73 changes: 34 additions & 39 deletions src/hooks/use-input.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {type Buffer} from 'node:buffer';
import {useEffect} from 'react';
import {isUpperCase} from 'is-upper-case';
import parseKeypress, {nonAlphanumericKeys} from '../parse-keypress.js';
import useStdin from './use-stdin.js';

/**
Expand Down Expand Up @@ -135,54 +137,47 @@ const useInput = (inputHandler: Handler, options: Options = {}) => {
}

const handleData = (data: Buffer) => {
let input = String(data);

const meta =
input.startsWith('\u001B\u001B') || // Meta + arrow keys
(input.startsWith('\u001B') && !input.startsWith('\u001B[')); // Meta + character

if (meta && input.length > 1) {
input = input.slice(1);
}
const keypress = parseKeypress(data);

const key = {
upArrow: input === '\u001B[A',
downArrow: input === '\u001B[B',
leftArrow: input === '\u001B[D',
rightArrow: input === '\u001B[C',
pageDown: input === '\u001B[6~',
pageUp: input === '\u001B[5~',
return: input === '\r',
escape: input === '\u001B',
ctrl: false,
// Shift + Tab
shift: input === '\u001B[Z',
tab: input === '\t' || input === '\u001B[Z',
backspace: input === '\u0008',
delete: input === '\u007F' || input === '\u001B[3~',
meta
upArrow: keypress.name === 'up',
downArrow: keypress.name === 'down',
leftArrow: keypress.name === 'left',
rightArrow: keypress.name === 'right',
pageDown: keypress.name === 'pagedown',
pageUp: keypress.name === 'pageup',
return: keypress.name === 'return',
escape: keypress.name === 'escape',
ctrl: keypress.ctrl,
shift: keypress.shift,
tab: keypress.name === 'tab',
backspace: keypress.name === 'backspace',
delete: keypress.name === 'delete',
// `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false
// but with option = true, so we need to take this into account here
// to avoid breaking changes in Ink.
// TODO(vadimdemedes): consider removing this in the next major version.
meta: keypress.meta || keypress.name === 'escape' || keypress.option
};

// Copied from `keypress` module
if (input <= '\u001A' && !key.return && !key.tab) {
// eslint-disable-next-line unicorn/prefer-code-point
input = String.fromCharCode(
// eslint-disable-next-line unicorn/prefer-code-point
input.charCodeAt(0) + 'a'.charCodeAt(0) - 1
);
let input = keypress.ctrl ? keypress.name : keypress.sequence;

key.ctrl = true;
if (nonAlphanumericKeys.includes(keypress.name)) {
input = '';
}

const isLatinUppercase = input >= 'A' && input <= 'Z';
const isCyrillicUppercase = input >= 'А' && input <= 'Я';

if (input.length === 1 && (isLatinUppercase || isCyrillicUppercase)) {
key.shift = true;
// Strip meta if it's still remaining after `parseKeypress`
// TODO(vadimdemedes): remove this in the next major version.
if (input.startsWith('\u001B')) {
input = input.slice(1);
}

if (key.tab || key.backspace || key.delete) {
input = '';
if (
input.length === 1 &&
typeof input[0] === 'string' &&
isUpperCase(input[0])
) {
key.shift = true;
}

// If app is not supposed to exit on Ctrl+C, then let input listener handle it
Expand Down
242 changes: 242 additions & 0 deletions src/parse-keypress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
// Copied from https://github.com/enquirer/enquirer/blob/36785f3399a41cd61e9d28d1eb9c2fcd73d69b4c/lib/keypress.js
import {Buffer} from 'node:buffer';

const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/;

const fnKeyRe =
/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/;

const keyName: Record<string, string> = {
/* xterm/gnome ESC O letter */
OP: 'f1',
OQ: 'f2',
OR: 'f3',
OS: 'f4',
/* xterm/rxvt ESC [ number ~ */
'[11~': 'f1',
'[12~': 'f2',
'[13~': 'f3',
'[14~': 'f4',
/* from Cygwin and used in libuv */
'[[A': 'f1',
'[[B': 'f2',
'[[C': 'f3',
'[[D': 'f4',
'[[E': 'f5',
/* common */
'[15~': 'f5',
'[17~': 'f6',
'[18~': 'f7',
'[19~': 'f8',
'[20~': 'f9',
'[21~': 'f10',
'[23~': 'f11',
'[24~': 'f12',
/* xterm ESC [ letter */
'[A': 'up',
'[B': 'down',
'[C': 'right',
'[D': 'left',
'[E': 'clear',
'[F': 'end',
'[H': 'home',
/* xterm/gnome ESC O letter */
OA: 'up',
OB: 'down',
OC: 'right',
OD: 'left',
OE: 'clear',
OF: 'end',
OH: 'home',
/* xterm/rxvt ESC [ number ~ */
'[1~': 'home',
'[2~': 'insert',
'[3~': 'delete',
'[4~': 'end',
'[5~': 'pageup',
'[6~': 'pagedown',
/* putty */
'[[5~': 'pageup',
'[[6~': 'pagedown',
/* rxvt */
'[7~': 'home',
'[8~': 'end',
/* rxvt keys with modifiers */
'[a': 'up',
'[b': 'down',
'[c': 'right',
'[d': 'left',
'[e': 'clear',

'[2$': 'insert',
'[3$': 'delete',
'[5$': 'pageup',
'[6$': 'pagedown',
'[7$': 'home',
'[8$': 'end',

Oa: 'up',
Ob: 'down',
Oc: 'right',
Od: 'left',
Oe: 'clear',

'[2^': 'insert',
'[3^': 'delete',
'[5^': 'pageup',
'[6^': 'pagedown',
'[7^': 'home',
'[8^': 'end',
/* misc. */
'[Z': 'tab'
};

export const nonAlphanumericKeys = [...Object.values(keyName), 'backspace'];

const isShiftKey = (code: string) => {
return [
'[a',
'[b',
'[c',
'[d',
'[e',
'[2$',
'[3$',
'[5$',
'[6$',
'[7$',
'[8$',
'[Z'
].includes(code);
};

const isCtrlKey = (code: string) => {
return [
'Oa',
'Ob',
'Oc',
'Od',
'Oe',
'[2^',
'[3^',
'[5^',
'[6^',
'[7^',
'[8^'
].includes(code);
};

type ParsedKey = {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
option: boolean;
sequence: string;
raw: string | undefined;
code?: string;
};

const parseKeypress = (s: Buffer | string = ''): ParsedKey => {
let parts;

if (Buffer.isBuffer(s)) {
if (s[0]! > 127 && s[1] === undefined) {
(s[0] as unknown as number) -= 128;
s = '\x1b' + String(s);
} else {
s = String(s);
}
} else if (s !== undefined && typeof s !== 'string') {
s = String(s);
} else if (!s) {
s = '';
}

const key: ParsedKey = {
name: '',
ctrl: false,
meta: false,
shift: false,
option: false,
sequence: s,
raw: s
};

key.sequence = key.sequence || s || key.name;

if (s === '\r') {
// carriage return
key.raw = undefined;
key.name = 'return';
} else if (s === '\n') {
// enter, should have been called linefeed
key.name = 'enter';
} else if (s === '\t') {
// tab
key.name = 'tab';
} else if (s === '\b' || s === '\x1b\b') {
// backspace or ctrl+h
key.name = 'backspace';
key.meta = s.charAt(0) === '\x1b';
} else if (s === '\x7f' || s === '\x1b\x7f') {
// 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.
// delete
key.name = 'delete';
key.meta = s.charAt(0) === '\x1b';
} else if (s === '\x1b' || s === '\x1b\x1b') {
// escape key
key.name = 'escape';
key.meta = s.length === 2;
} else if (s === ' ' || s === '\x1b ') {
key.name = 'space';
key.meta = s.length === 2;
} else if (s <= '\x1a') {
// ctrl+letter
key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1);
key.ctrl = true;
} else if (s.length === 1 && s >= '0' && s <= '9') {
// number
key.name = 'number';
} else if (s.length === 1 && s >= 'a' && s <= 'z') {
// lowercase letter
key.name = s;
} else if (s.length === 1 && s >= 'A' && s <= 'Z') {
// shift+letter
key.name = s.toLowerCase();
key.shift = true;
} else if ((parts = metaKeyCodeRe.exec(s))) {
// meta+character key
key.meta = true;
key.shift = /^[A-Z]$/.test(parts[1]!);
} else if ((parts = fnKeyRe.exec(s))) {
const segs = [...s];

if (segs[0] === '\u001b' && segs[1] === '\u001b') {
key.option = true;
}

// ansi escape sequence
// reassemble the key code leaving out leading \x1b's,
// the modifier key bitflag and any meaningless "1;" sequence
const code = [parts[1], parts[2], parts[4], parts[6]]
.filter(Boolean)
.join('');

const modifier = ((parts[3] || parts[5] || 1) as number) - 1;

// Parse the key modifier
key.ctrl = !!(modifier & 4);
key.meta = !!(modifier & 10);
key.shift = !!(modifier & 1);
key.code = code;

key.name = keyName[code]!;
key.shift = isShiftKey(code) || key.shift;
key.ctrl = isCtrlKey(code) || key.ctrl;
}

return key;
};

export default parseKeypress;
20 changes: 20 additions & 0 deletions test/fixtures/use-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,26 @@ function UserInput({test}: {test: string | undefined}) {
return;
}

if (test === 'upArrowCtrl' && key.upArrow && key.ctrl) {
exit();
return;
}

if (test === 'downArrowCtrl' && key.downArrow && key.ctrl) {
exit();
return;
}

if (test === 'leftArrowCtrl' && key.leftArrow && key.ctrl) {
exit();
return;
}

if (test === 'rightArrowCtrl' && key.rightArrow && key.ctrl) {
exit();
return;
}

if (test === 'pageDown' && key.pageDown && !key.meta) {
exit();
return;
Expand Down

0 comments on commit 6f99ca3

Please sign in to comment.