/* * Copyright (c) 2022 R3BL LLC. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import EventEmitter from "events" import { Key, useInput, useStdin } from "ink" import StdinContext from "ink/build/components/StdinContext" import { noop } from "lodash" import React, { FC, useMemo } from "react" import { anyToString } from "../lang-utils" import { Pair } from "../lang-utils/core" import { _callIfTruthyWithReturn } from "../lang-utils/expression-lang-utils" import { Option } from "../lang-utils/rust-lang-utils" import { IsActive, StateHook, useEventEmitter, useStateSafely } from "../tui-core" import { Keypress } from "./keypress" import { createFromInk } from "./keypress-builder-ink" import { createFromKeypress } from "./keypress-builder-readline" import { ReadlineKey } from "./readline-config" import { tryToRunActionForShortcut } from "./use-keyboard-internal" import { HandleNodeKeypressFn, useNodeKeypress } from "./use-node-keypress" import { isTTY } from "./utils" /** Ink has a hook that is supposed to be used to get user input from the keyboard called `useInput`, which comes from the `ink` package. `TextInput` is built on top of this hook. `TextInput` comes from the npm package `ink-text-input`. 1. [useInput sets raw mode to false](https://github.com/vadimdemedes/ink/blob/master/src/hooks/use-input.ts#L126) 2. [TextInput uses useInput](https://github.com/vadimdemedes/ink-text-input/blob/master/source/index.tsx#L117) The `useKeyboard` hook makes some assumptions. 1. It will set raw mode to true when used. 2. It will turn raw mode to false when unmounted. The problem w/ `TextInput` using `useInput` and then turning raw mode to off when focus changes on `TextInput` simply causes the Node.js process to exit, since there are no active listeners attached to it 🤯. To mitigate this problem is really simple - just make sure to call `useInput(noop)` somewhere in the component that includes `TextInput`! ⚠ However, this does not get rid of the default "ctrl+c" handling, which is to exit the app (this is how useInput behaves by default). You can override it by wrapping your Ink components in a Provider, like so: ``` ``` or simply: ``` ``` */ export const UseKeyboardWrapper: FC<{ children?: React.ReactNode }> = ({ children }) => { // https://www.carlrippon.com/react-children-with-typescript/ return ( {children} ) } /** @deprecated Please use UseKeyboardWrapper instead. */ export const usePreventUseInputFromSettingRawModeToFalseAndExiting = () => useInput(noop) // Types. export type KeyboardInputHandlerFn = (input: KeypressOption) => void export const createNewShortcutToActionMap = (): ShortcutToActionMap => new Map() export class UseKeyboardReturnValue { constructor( readonly keyPress: KeypressOption, readonly inRawMode: boolean ) { } toArray(): Pair { return [ this.keyPress, this.inRawMode ] } } export type ActionFn = () => void export type Shortcut = string // "Shortcut" aka "KeyBinding". export type ShortcutToActionMap = Map export type NodeKeypressTesting = { /** Replaces Node.js process.stdin keypress source. */ emitter: EventEmitter, /** Choice of event name representing keypress, to fire and listen for. */ eventName: string, } type Common = { type: "fun", matchKeypressFn: KeyboardInputHandlerFn, options: IsActive } | { type: "map", map: ShortcutToActionMap, options: IsActive } | { type: "map-cached", createShortcutsFn: () => ShortcutToActionMap, options: IsActive } interface UseKeyboardConfigInkCompat { type: "ink-compat" args: Common } interface UseKeyboardConfigNodeKeypress { type: "node-keypress" args: Common testing?: NodeKeypressTesting } export type UseKeyboardConfig = UseKeyboardConfigInkCompat | UseKeyboardConfigNodeKeypress // Hooks. export const useKeyboardBuilder = (config: UseKeyboardConfig): UseKeyboardReturnValue => { switch (config.type) { case "node-keypress": { switch (config.args.type) { case "fun": return useKeyboard( config.args.matchKeypressFn, config.args.options, config.testing ) case "map-cached": return useKeyboardWithMapCached( config.args.createShortcutsFn, config.args.options, config.testing ) case "map": return useKeyboardWithMap( config.args.map, config.args.options, config.testing ) } } case "ink-compat": { switch (config.args.type) { case "fun": return useKeyboardCompatInk( config.args.matchKeypressFn, config.args.options, ) case "map-cached": return useKeyboardCompatInkWithMapCached( config.args.createShortcutsFn, config.args.options ) case "map": return useKeyboardCompatInkWithMap( config.args.map, config.args.options, ) } } } } export type KeypressOption = Option> /** * @return [keyPress, inRawMode] - inRawMode is false means keyboard input is disabled in * terminal. keyPress is the key that the user pressed (eg: "ctrl+k", "backspace", "shift+A"). */ export const useKeyboard = ( processKeypressFn: KeyboardInputHandlerFn, options: IsActive = { isActive: true }, testing?: NodeKeypressTesting ): UseKeyboardReturnValue => { const [ keyPress, setKeyPress ]: StateHook = useStateSafely(Option.none()).asArray() const dispatchKeypressOption = (keypressOption: KeypressOption) => { setKeyPress(keypressOption) processKeypressFn(keypressOption) } // Testing bypass process.stdin as the event emitter for "keypress" events (via readline). // @see multi-select-input.tsx return _callIfTruthyWithReturn( testing, // Testing mode. ({ emitter, eventName }) => { const eventListener = (args: any[]) => { const arg = args[ 0 ] const argIsKeypress = arg instanceof Keypress if (argIsKeypress) { const keypress = arg as Keypress dispatchKeypressOption(Option.create(keypress)) } else { const msg = anyToString(arg) let error = new Error(`Expected keypress event, got ${msg}`) console.error(error) } } useEventEmitter(emitter, eventName, eventListener, options) return new UseKeyboardReturnValue(keyPress, true) }, // Production mode. () => { if (!isTTY()) return new UseKeyboardReturnValue(Option.none(), false) const onKeypress: HandleNodeKeypressFn = (input: string, key: ReadlineKey) => { const keypressOption: KeypressOption = Option.create(createFromKeypress(key, input)) dispatchKeypressOption(keypressOption) } useNodeKeypress(onKeypress, options) return new UseKeyboardReturnValue(keyPress, true) } ) } /** * @return [keyPress, inRawMode] - inRawMode is false means keyboard input is disabled in * terminal. keyPress is the key that the user pressed (eg: "ctrl+k", "backspace", "shift+A"). */ export const useKeyboardWithMap = ( map: ShortcutToActionMap, options: IsActive = { isActive: true }, testing?: NodeKeypressTesting ): UseKeyboardReturnValue => useKeyboard( keyPress => tryToRunActionForShortcut(keyPress, map), options, testing ) /** * @return [keyPress, inRawMode] - inRawMode is false means keyboard input is disabled in * terminal. keyPress is the key that the user pressed (eg: "ctrl+k", "backspace", "shift+A"). */ export const useKeyboardWithMapCached = ( createMapFn: () => ShortcutToActionMap, options: IsActive = { isActive: true }, testing?: NodeKeypressTesting ): UseKeyboardReturnValue => { const cachedMap: ShortcutToActionMap = useMemo(() => createMapFn(), [ options.isActive ]) return useKeyboardWithMap(cachedMap, options, testing) } /** * @return [keyPress, inRawMode] - inRawMode is false means keyboard input is disabled in * terminal. keyPress is the key that the user pressed (eg: "ctrl+k", "backspace", "shift+A"). */ export const useKeyboardCompatInk = ( processKeypressFn: KeyboardInputHandlerFn, options: IsActive = { isActive: true }, ): UseKeyboardReturnValue => { const [ keyPress, setKeyPress ]: StateHook = useStateSafely(Option.none()).asArray() const { isRawModeSupported: inRawMode } = useStdin() // Can only call useInput in raw mode. if (!inRawMode) return new UseKeyboardReturnValue(Option.none(), false) const onKeypress = (input: string, key: Key) => { const userInputKeyPressOption: KeypressOption = Option.create(createFromInk(key, input)) setKeyPress(userInputKeyPressOption) processKeypressFn(userInputKeyPressOption) } useInput(onKeypress, options) return new UseKeyboardReturnValue(keyPress, inRawMode) } /** * @return [keyPress, inRawMode] - inRawMode is false means keyboard input is disabled in * terminal. keyPress is the key that the user pressed (eg: "ctrl+k", "backspace", "shift+A"). */ export const useKeyboardCompatInkWithMap = ( map: ShortcutToActionMap, options: IsActive = { isActive: true }, ): UseKeyboardReturnValue => useKeyboardCompatInk(keyPress => tryToRunActionForShortcut(keyPress, map), options) /** * @return [keyPress, inRawMode] - inRawMode is false means keyboard input is disabled in * terminal. keyPress is the key that the user pressed (eg: "ctrl+k", "backspace", "shift+A"). */ export const useKeyboardCompatInkWithMapCached = ( createMapFn: () => ShortcutToActionMap, options: IsActive = { isActive: true }, ): UseKeyboardReturnValue => { const cachedMap: ShortcutToActionMap = useMemo(() => createMapFn(), [ options.isActive ]) return useKeyboardCompatInkWithMap(cachedMap) }