Reference handbook for using Ink v3.2.0 components (w/ React, Node.js and TypeScript)
- Introduction
- What is Ink
- Items covered in this article
- Example 1 - moving a component with arrow keys
- Example 2 - keyboard input & focus manipulation
- Example 3 - full screen app
Introduction #
This article is reference handbook for using Ink v3.2.0 (with TypeScript and React) components to create CLI apps. IDEA Ultimate / Webstorm project files are provided.
To get started w/ Ink v3 please check out this introductory article and this advanced article.
โ ๏ธ This isnโt a reference for React, Node.js, TypeScript, or CSS #
Learn more about these topics on developerlife.com:
Resource Notes Node.js handbook Learn Node.js w/ TypeScript React Hooks handbook Learn React, Redux, and Testing w/ TypeScript CSS handbook Learn CSS, grid, flexbox, to implement responsive design
What is Ink #
Ink is a React renderer written in TypeScript using the react-reconciler
package. So
instead of rendering React components to the DOM Ink actually renders them to a terminal based UI.
With it, you can make CLI apps that look like this:
Ink supplies its own host UI components (eg: <Text>
) which has nothing to do w/
react-dom host components that come out of the box (eg: <img>
) w/ React.
Ink supplied host UI components are rendered to a terminal based UI by using:
console
streams to manipulate the output to the console, and input from the console.- Yoga to implement CSS flexbox layout and positioning. Yoga is written in C++ that allows flexbox layout itself to be implemented in various platforms (like Lithio on Android, Node.js, .Net, etc).
- You can take a look at the dependencies that Ink has in its
package.json
. It relies on packages like:chalk
,cli-boxes
,cli-cursor
,yoga-layout-prebuilt
,react-reconciler
,react-devtools-core
.
Items covered in this article #
There are a wealth of examples that you can find in the ink repo itself. I suggest browsing through all of them to get some sense of how to use all the host components and hooks.
In this article, we will walk through three examples in order to get a sense of how to use the entire API surface of Ink. There are a lot of community contributions in the form of npm packages that are built using ink that we will not cover here. These examples will cover the usage of the following API surface of ink.
- Components
Text
Box
Border
Newline
- Hooks
useInput
useApp
useStdin
useFocus
useFocusManager
๐ช You can find the source code for these examples in the ink-cli-app3 repo. Please clone this repo to run the samples below on your computer.
Then navigate to the
ts-scratch/ink-cli-app3/
folder which contains the following.
package.json
and npm start scripts. Make sure to runnpm i
after cloning this repo.- IDEA & VSCode projects. You can open this folder in your favorite IDE.
Example 1 - moving a component with arrow keys #
This small example is a CLI app that allows the user to move a component on the screen the cursor
around using arrow up, down, left, and right keys. It shows how we can use the useInput()
hook to
make this happen. Hereโs the code.
๐ช Hereโs the source code for
use-input.tsx
.
import React, { useState } from "react"
import { Box, render, Text, useApp, useInput } from "ink"
import { _callIfTruthy } from "r3bl-ts-utils"
const UseInputExample = () => {
const { exit } = useApp()
const [x, setX] = useState(1)
const [y, setY] = useState(1)
useInput((input, key) => {
_callIfTruthy(input === "q", () => exit())
_callIfTruthy(key.leftArrow, () => setX(Math.max(1, x - 1)))
_callIfTruthy(key.rightArrow, () => setX(Math.min(20, x + 1)))
_callIfTruthy(key.upArrow, () => setY(Math.max(1, y - 1)))
_callIfTruthy(key.downArrow, () => setY(Math.min(10, y + 1)))
})
return (
<Box flexDirection="column">
<Text color={"green"}>Use arrow keys to move the X.</Text>
<Text color={"red"}>Press โqโ to exit.</Text>
<Box height={12} paddingLeft={x} paddingTop={y}>
<Text color={"blue"}>X</Text>
</Box>
</Box>
)
}
render(<UseInputExample />)
Hereโs the output that it produces.
$ node -r tsm src/examples/use-input.tsx Use arrow keys to move the X. Press โqโ to exit. X
Hereโs a brief description of the lifecycle of the app.
- First it is started from the terminal when the script is executed using the
node ...
command shown above. - Once the
useInput()
hook is called the Node.js process is listening for input events onprocess.stdin
. This prevents the Node.js process from exiting once the app is rendered once. - When the user presses up, down, left, or right, this causes the X and Y padding of the app to be
changed, which makes it look like the
X
is moving around the terminal. - When the user presses q or Ctrl+c this will exit the Node.js process. The
useApp()
hook supplies anexit()
function which can be used to do this.
Example 2 - keyboard input & focus manipulation #
This medium size example goes deep into the hooks useFocus()
and useFocusManager()
to
demonstrate how to manage input focus in a CLI app. Not only can Tab and
Shift+Tab be used to move keyboard focus from one component to another, but shortcuts are
provided that allow giving focus to a given component (by pressing Shift+1 to focus the
first component, Shift+2 the second, and Shift+3 the third). A debug component
is provided which shows what keys are actually being pressed. Additionally, flexbox is used via
Box
to organize the components in a sophisticated way.
๐ช Hereโs the source code for
use-focus.tsx
.
import { Box, Newline, render, Text, useApp, useFocus, useFocusManager } from "ink"
import {
_callIfTrue,
KeyboardInputHandlerFn,
makeReactElementFromArray,
useKeyboard,
UserInputKeyPress,
} from "r3bl-ts-utils"
import React, { FC } from "react"
//#region Main functional component.
const UseFocusExample: FC = function (): JSX.Element {
const [keyPress, inRawMode] = useKeyboard(
onKeyPress.bind({ app: useApp(), focusManager: useFocusManager() })
)
return (
<Box flexDirection="column">
{keyPress && (
<Row_Debug
inRawMode={inRawMode}
keyPressed={keyPress?.key}
inputPressed={keyPress?.input}
/>
)}
<Row_Instructions />
<Row_FocusableItems />
</Box>
)
}
//#endregion
//#region Keypress handler.
const onKeyPress: KeyboardInputHandlerFn = function (
this: { app: ReturnType<typeof useApp>; focusManager: ReturnType<typeof useFocusManager> },
userInputKeyPress: UserInputKeyPress
) {
const { app, focusManager } = this
const { exit } = app
const { focus } = focusManager
const { input, key } = userInputKeyPress
_callIfTrue(input === "q", exit)
_callIfTrue(key === "ctrl" && input === "q", exit)
_callIfTrue(input === "!", () => focus("1"))
_callIfTrue(input === "@", () => focus("2"))
_callIfTrue(input === "#", () => focus("3"))
}
//#endregion
//#region UI.
function Row_Debug(props: {
inRawMode: boolean
keyPressed: string | undefined
inputPressed: string | undefined
}) {
const { inputPressed, keyPressed, inRawMode } = props
return inRawMode ? (
<>
<Text color={"magenta"}>input: {inputPressed}</Text>
<Text color={"gray"}>key: {keyPressed}</Text>
</>
) : (
<Text>keyb disabled</Text>
)
}
const Row_Instructions: FC = function (): JSX.Element {
return makeReactElementFromArray(
[
["blue", "Press Tab to focus next element"],
["blue", "Shift+Tab to focus previous element"],
["blue", "Esc to reset focus."],
["green", "Press Shift+<n> to directly focus on 1st through 3rd item."],
["red", "To exit, press Ctrl+q, or q"],
],
(item: string[], id: number): JSX.Element => (
<Text color={item[0]} key={id}>
{item[1]}
</Text>
)
)
}
const Row_FocusableItems: FC = function (): JSX.Element {
return (
<Box padding={1} flexDirection="row" justifyContent={"space-between"}>
<FocusableItem id="1" label="First" />
<FocusableItem id="2" label="Second" />
<FocusableItem id="3" label="Third" />
</Box>
)
}
const FocusableItem: FC<{ label: string; id: string }> = function ({ label, id }): JSX.Element {
const { isFocused } = useFocus({ id })
return (
<Text>
{label}
{isFocused ? (
<>
<Newline />
<Text color="green">(*)</Text>
</>
) : (
<>
<Newline />
<Text color="gray">n/a</Text>
</>
)}
</Text>
)
}
//#endregion
render(<UseFocusExample />)
This is what the output looks like when Shift+2 (ie @
) is typed to gain focus to the
โSecondโ component.
$ node -r tsm src/examples/use-focus.tsx input: @ key: n/a Press Tab to focus next element Shift+Tab to focus previous element Esc to reset focus. Press Shift+<n> to directly focus on 1st through 3rd item. To exit, press Ctrl+q, or q First Second Third n/a (*) n/a
Hereโs a brief description of the lifecycle of the app.
- First it is started from the terminal when the script is executed using the
node ...
command shown above. - The main UI is a flexbox container (
flexDirection="column"
) which lays out three components top to bottom.- The first row is
Row_Debug
component which displays which key is currently pressed if any. - The second row is
Row_Instructions
component which displays the keyboard shortcuts to use to interact with the CLI app. - The third row is
Row_FocusableItems
component which is another flexbox container (flexDirection="row"
this time) which lays out the focusableText
components left to right.
- The first row is
- The custom hook
useKeyboard()
gets the key that is pressed (theUserInput
object). It works hand in hand w/ theFocusableItem
component in order to make all of this work.- This custom hook just calls the
useInput()
hook (just like in the example above). This prevents the Node.js process from exiting once the app is rendered once. - When the user presses keys, this is saved to the state and then the
Row_Debug
is updated w/ this information. - When Shift+1, Shift+2, Shift+3 is pressed, the
const { focus } = useFocusManager()
hook is used to directly bring focus to theText
components (via a call tofocus(id)
. As a setup for this to work, when each focusable component is declared (insideRow_FocusableItems
) theuseFocus({ id })
hook has to be given the same ID that is used to bring focus directly to it. - In
FocusableItem
, theisFocused
boolean from the hook callconst { isFocused } = useFocus({ id })
is used to determine whether that component currently has keyboard focus and if so, it renders itself slightly differently. - When the user presses q, Ctrl+q, or Ctrl+c this will exit the
Node.js process. The
useApp()
hook supplies anexit()
function which can be used to do this.
- This custom hook just calls the
Example 3 - full screen app #
This large size example is similar to the medium size one, except that it has a more complex UI. It takes up the full height and width of the terminal that it is started in. And it listens for keyboard input. It also uses a lot of custom hooks. For the UI, it splits the screen into two columns, and adds components to each column. The UI also features a ticking clock that updates every 1 second.
๐ช Hereโs the source code for
cli.tsx
. You can find all the other files that are loaded bycli.tsx
in the repo.
You can run the program using the following command
npm
run
start-dev
.
The following output is produced.
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ โ 1st column 2nd column โ โ Hello Item 1 โ โ Stranger Item 2 โ โ 7:16:58 PM rows: 19, columns: 58 โ โ keyb enabled โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
Letโs start w/ the main app.tsx
file which is loaded via cli.tsx
when the CLI app is run from
the terminal. Hereโs the one line that kicks everything off. appFn
is a functional React
component.
export const appFn: FC<{ name: string }> = ({ name }) => render(runHooks(name))
There are quite a few hooks which are executed (custom ones) that return an object, which is then
used by the render
function to paint the UI. Hereโs what the runHooks()
function looks like.
function runHooks(name: string): LocalVars {
usePreventProcessExitDuringTesting() // For testing using `npm run start-dev-watch`.
const ttySize: TTYSize = useTTYSize()
const time = useClock()
const inRawMode = _let(useApp(), (it) => {
const [_, inRawMode] = useKeyboard(onKeyboardFn.bind({ useApp: it }))
return inRawMode
})
return {
name,
ttySize,
time,
inRawMode,
}
}
interface LocalVars {
ttySize: TTYSize
inRawMode: boolean
time: number
name: string
}
Here is a brief overview of what the hooks do.
usePreventProcessExitDuringTesting()
is a hook that comes fromr3bl-ts-utils
. It checks to see if the terminal is in raw mode (which is needed to enable keyboard input). If itโs not in raw mode & keyboard input isnโt possible, it just starts aTimer
that doesnโt do anything so that the Node.js process wonโt exit.useTTYSize()
is a hook that comes fromr3bl-ts-utils
. It provides the width and height of the terminal. This is used by the UI code below in order to take up the full width and height of the terminal. A โresizeโ listener is also registered so that if this terminal is resized, the new width and height, will be propagated and the UI re-rendered by React.useClock()
is a hook that comes fromr3bl-ts-utils
that simply starts aTimer
and returns the current time. This time is displayed in the UI. It is updated every second andsetState()
is used in order to re-render the React UI.useKeyboard()
is a hook that comes fromr3bl-ts-utils
that simply attaches the given function to handle key input from the terminal. TheuseApp()
hook is provided by Ink, and it allows access to theexit()
function which can be used to exit the CLI app. This is useful when you create keyboard shortcuts that allow the user to exit the terminal app (eg: Ctrl+q).- Finally, an object (implementing
LocalVars
) is returned that is used by therender()
function in order to paint the UI. This explicit passing of local state is meant to make it clear that this is a stateless functional component.
๐ช There are quite a few hooks & utility classes that are provided by
r3bl-ts-utils
that are used here (likeTimer
,useTTYSize()
,_let()
, etc.). Learn more about this package here.
Hereโs the code that provides the function for keyboard input handling.
/**
* ๐ช This function implements `KeyboardInputHandlerFn` interface.
*
* `this` binds it to an object of type OnKeyboardContext. Since this function is a callback that's
* executed by Ink itself, it can't make any calls to hooks (like `useApp()` which is why re-binding
* `this` is needed).
*/
function onKeyboardFn(
this: {
useApp: ReturnType<typeof useApp>
},
keyPress: UserInputKeyPress
) {
const { useApp } = this
_callIfTrue(keyPress.toString() === "ctrl+q", useApp.exit)
_callIfTrue(keyPress.toString() === "q", useApp.exit)
_callIfTrue(keyPress.toString() === "escape", useApp.exit)
}
And hereโs the function that renders the UI, using the objects in LocalVars
that is generated by
runHooks()
.
//#region render().
function render(locals: LocalVars) {
const { inRawMode, ttySize, time } = locals
return (
<Box flexDirection="row" alignSelf={"center"} height={ttySize.rows}>
<Box
borderStyle="round"
borderColor={Style.brandColor}
flexDirection="row"
paddingLeft={1}
paddingRight={1}
width={Style.appWidth}
>
<Box flexDirection="column" flexBasis={Style.column1Width}>
{renderColumn1(locals)}
{TextStyle.subHeading(new Date(time).toLocaleTimeString())}
{inRawMode ? TextStyle.subHeading("keyb enabled") : TextStyle.subHeading("keyb disabled")}
</Box>
<Box flexDirection="column" flexGrow={1}>
{renderColumn2(locals)}
</Box>
</Box>
</Box>
)
}
//#endregion
//#region UI.
function renderColumn1(locals: LocalVars): JSX.Element {
const { name } = locals
return (
<>
{TextStyle.heading("1st column")}
{TextStyle.subHeading("Hello")}
{TextStyle.emphasis(name)}
</>
)
}
function renderColumn2(locals: LocalVars): JSX.Element {
const { ttySize } = locals
return (
<>
{TextStyle.heading("2nd column")}
{TextStyle.styleNormal("Item 1")}
{TextStyle.styleNormal("Item 2")}
{TextStyle.heading(ttySize.toString())}
</>
)
}
//#endregion
Most of this code is flexbox in order to create the 2 column layout that takes up the entire width and height of the terminal in which this app is run.
๐ Watch Rust ๐ฆ live coding videos on our YouTube Channel.
๐ฆ Install our useful Rust command line apps usingcargo install r3bl-cmdr
(they are from the r3bl-open-core project):
- ๐ฑ
giti
: run interactive git commands with confidence in your terminal- ๐ฆ
edi
: edit Markdown with style in your terminalgiti in action
edi in action