The r3bl-ts-utils
package is a set of useful TypeScript functions and classes that can be used in
Node.js and browser environments. They are inspired by Kotlin stdlib, and Rust to write code as
expressions rather than statements, colorized text, powerful Text User Interface (TUI) framework to
build powerful CLI apps, cache, and timer utilities.
The following groups of functionality are provided in this package.
- Scope functions inspired by Kotlin stdlib scope functions (
_also
,_let
,_apply
,_with
, etc.). These allow you to write code that is more expression based rather than statement based. - Misc language functions, that allow you to write conditional code that is more expression based (like in Kotlin and Rust) rather than statement based (in JavaScript). Also provides some core classes that make it easy to work w/ data classes.
- Rust inspired language functions, that allow you to avoid using
null
orundefined
in your code. This is inspired by Rust'sOption
andResult
types. - Timer utilities that are easier and more robust to work w/ than
setInterval()
. These can be used to perform any recurring tasks that are on a fixed interval timer. - Cache utilities that provide a cache that you can use that provides multiple eviction policies.
- Text User Interface (TUI) library is built on top of React and Ink and provides a comprehensive set of UI components, helper functions, types, and classes, and a full TUI application framework. You can use this to build very powerful feature rich TUI apps, which are command line interface apps that run in the terminal, that are keyboard driven, and have sophisticated layout and responsiveness support. This includes the following:
This module is written entirely in TypeScript, and is configured to be a CommonJS module.
💡 Here's more information on CommonJS, ESM, and hybrid modules.
To install the package, simply run the following in the top level folder of your project.
npm i r3bl-ts-utils
For more information on how this package was created, or to facilitate a deep dive of the code written here, please read the Node.js w/ TypeScript handbook on developerlife.com.
Here are some important links for this package.
- Scope functions
- Misc language functions
- Rust language functions
- Timer utils
- Cache utils
- Text User Interface (TUI)
- Build, test, and publish this package
- IDEA configuration
- VSCode settings
- References
The scope functions mimic Kotlin's stdlib
scope functions (also
, let
, apply
, with
) one to
one. So here are four examples of using them. You can browse the source here.
The tests for this library are worth taking a look at to get a sense of how to use them.
_also()
takes a contextObject
, passes it to the ReceiverFn
, and returns the
contextObject
. Here's an example.
import { _also } from "r3bl-ts-utils"
const spans = _also(new Array<string>(3), (spans: string[]) => {
spans[0] = "one"
spans[1] = "two"
spans[2] = "three"
})
const output = spans.join(", ")
console.log(output)
You can name the argument to the
ReceiverFn
anything you like and not justit
(which is the common practice in Kotlin).
_alsoAsync()
is not part of Kotlin's stdlib
scope functions, but it behaves similarly to
_also()
except that it accepts an async receiver function (ReceiverFnAsync
). And it returns the
contextObject
and a promise from the async receiver function. Here's an example.
import { _alsoAsync } from "r3bl-ts-utils"
const contextObject = "_alsoAsync"
let flag = false
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
const asyncFn: ReceiverFnAsync<string, boolean> = async (it) =>
new Promise<boolean>((resolveFn) => {
const _fun = () => {
expect(it).toEqual(contextObject)
flag = true
resolveFn(true)
}
// Delay execution of _fun to next iteration of event loop.
// https://nodejs.dev/learn/understanding-setimmediate
setImmediate(_fun)
})
const { contextObject: obj, promiseFromReceiverFn: promise } = _alsoAsync(contextObject, asyncFn)
expect(obj).toEqual(contextObject)
expect(flag).toBeFalsy()
const value = await promise
expect(value).toBeTruthy()
expect(flag).toBeTruthy()
_alsoSafe()
takes a contextObject
, passes a deep copy of it to the ReceiverFn
, and
returns this deep copy of contextObject
. Here's an example.
const contextObject = { foo: 1 }
let flag = false
const fun = (it: typeof contextObject): void => {
expect(it).not.toBe(contextObject)
flag = true
}
const returnValue = _alsoSafe(contextObject, fun)
expect(returnValue).not.toBe(contextObject)
expect(returnValue).toEqual(contextObject)
expect(flag).toBeTruthy()
_alsoSafeAsync()
is not part of Kotlin's stdlib
scope functions, but it behaves
similarly to _alsoAsync()
except that it accepts an async receiver function (ReceiverFnAsync
).
And it returns a deep copy of then contextObject
and a promise from the async receiver function.
Here's an example.
const contextObject = { foo: 1 }
let flag = false
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
const fun1: ReceiverFnAsync<typeof contextObject, boolean> = async (it) =>
new Promise<boolean>((resolveFn) => {
const _fun = () => {
expect(it).toEqual(contextObject)
expect(it).not.toBe(contextObject)
flag = true
resolveFn(true)
}
// Delay execution of _fun to next iteration of event loop.
// https://nodejs.dev/learn/understanding-setimmediate
setImmediate(_fun)
})
const { contextObjectDeepCopy: obj, promiseFromReceiverFn: promise } = _alsoSafeAsync(
contextObject,
fun1
)
expect(obj).toEqual(contextObject)
expect(flag).toBeFalsy()
const value = await promise
expect(value).toBeTruthy()
expect(flag).toBeTruthy()
_then()
is not part of Kotlin's stdlib
scope functions, but it behaves similarly to
_also()
, except that it takes an array of receiver functions (each of which accepts a
contextObject
argument), and runs them all sequentially. It returns contextObject
just like
_also()
. Here's an example.
import { _then } from "r3bl-ts-utils"
const buffer = ""
const fun1: ReceiverFn<string> = (it) => (buffer += it)
const fun2: ReceiverFn<string> = (it) => (buffer += it)
const returnValue = _then("foo", fun1, fun2)
expect(returnValue).toEqual(contextObject)
expect(buffer).toEqual("foofoo")
_let()
takes a contextObject
, passes it to the ReceiverFnWithReturn
, and returns its
return value. Here's an example.
import { _let } from "r3bl-ts-utils"
const contextObject = "string"
const returnValue = _let(contextObject, (it) => {
return `my-${it}`
})
expect(returnValue).toEqual(`my-string`)
The call to
_let(...)
returns the value of the 2nd argumentReceiverFnWithReturn
and not the first argumentcontextObject
.
_letSafe()
takes a contextObject
, and passes a deep copy of it to the
ReceiverFnWithReturn
, and returns its return value. Here's an example.
const contextObject = { foo: 1 }
const receiverFnReturnValue = Symbol()
// https://jasmine.github.io/2.1/introduction#section-Spies:_%3Ccode%3Eand.callThrough%3C/code%3E
const myReceiverFn: ReceiverFnWithReturn<typeof contextObject, symbol> = (it) => {
expect(it).toEqual(contextObject)
expect(it).not.toBe(contextObject)
return receiverFnReturnValue
}
const spyObjectContainingFn = { myReceiverFn }
spyOn(spyObjectContainingFn, "myReceiverFn").and.callThrough()
const returnValue = _letSafe(contextObject, spyObjectContainingFn.myReceiverFn)
expect(returnValue).toEqual(receiverFnReturnValue)
expect(spyObjectContainingFn.myReceiverFn).toHaveBeenCalled()
_apply()
takes a contextObject
, binds it to ImplicitReceiverObject
's this
, calls it,
then returns the contextObject
. Here's an example.
import { _apply, ImplicitReceiverObject } from "r3bl-ts-utils"
const contextObject: string = "string"
const myImplicitReceiverObject: ImplicitReceiverObject<string> = {
fnWithReboundThis: function (): string {
expect(this).toEqual(contextObject)
return contextObject
},
}
const returnValue = _apply(contextObject, myImplicitReceiverObject)
expect(returnValue).toEqual(contextObject)
In the code above, you can just inline the
myImplicitReceiverObject
into the line where you call_apply()
. The code is written in this verbose way just for clarity.⚠ Please note that the
fnWithReboundThis
can't be an arrow function, since it would not allowthis
to be rebound.
_with()
takes a contextObject
, binds it to ImplicitReceiverObjectWithReturn
's this
,
calls it then returns the its return value. Here's an example.
import { _with, ImplicitReceiverObjectWithReturn } from "r3bl-ts-utils"
const contextObject: string = "some_data"
const hardcodedReceiverReturnValue: Symbol = Symbol()
const returnValue = _with(contextObject, {
fnWithReboundThis: function (): symbol {
expect(this).toEqual(contextObject)
return hardcodedReceiverReturnValue
},
})
expect(returnValue).toEqual(hardcodedReceiverReturnValue)
In the code above, you can just inline the
myImplicitReceiverObjectWithReturn
into the line where you call_with()
. The code is show in this verbose way just for clarity.⚠ Please note that the
fnWithReboundThis
can't be an arrow function, since it would not allowthis
to be rebound.
Inspired by Kotlin's data classes, the Data
class is provided which makes it really easy to get a
toString()
implementation that pretty formats the string for free. It makes use of the
anyToString()
function that you can choose to use instead of using the class. Here's an example.
class MapData extends Data {
constructor(
readonly name: string = "MapData contains properties: string, string, Map, Array",
readonly type: string = "string",
readonly map: Map<any, any> = new Map().set("foo", "1").set("bar", "2"),
readonly array: Array<string> = ["one", "two", "three"]
) {
super()
}
}
console.log(new MapData().toString())
This is loosely inspired by the Kotlin scope function style and the repeat
function in JavaScript.
You can pass a lambda and have it repeat n
times. Here's an example.
let count = 0
_repeat(5, () => count++)
expect(count).toEqual(5)
This is inspired from the style of Kotlin scope functions above. If the
contextObject
is truthy, then the
lambda is executed & it gets the type-safe non-null reference to contextObject
. Here's an example.
type CtxObjType = { foo: number }
const contextObject: CtxObjType | undefined = { foo: 1 }
let executed = false
const returnValue = _callIfTruthy(contextObject, (it: CtxObjType) => {
expect(it).toBeDefined()
expect(it).toEqual({ foo: 1 })
executed = true
})
expect(returnValue).toBeTruthy()
expect(returnValue).toBe(contextObject)
expect(executed).toBeTruthy()
This is similar to _callIfTruthy
except that it will return the return value of the lambda that's
passed.
This is an interesting control flow expression. It is another way to represent an if statement
.
Three arguments need to be passed.
- The first is the condition variable which can evaluate to truthy or falsy.
- The 2nd is the lambda that is executed if the condition holds true. The return value of this lamba is returned by the expression.
- The 3rd argument is the lambda that's executed if the condition holds false. The return value of this lambda is returned by the expression.
// Condition is truthy.
_also(
{
onTrueFlag: false,
onFalseFlag: false,
},
(flags) => {
const returnValue = _callIfTruthyWithReturn(
"foo",
(it) => {
expect(it).toEqual("foo")
flags.onTrueFlag = true
return "true"
},
() => {
flags.onFalseFlag = false
return "false"
}
)
expect(returnValue).toEqual("true")
expect(flags.onTrueFlag).toBeTruthy()
expect(flags.onFalseFlag).toBeFalsy()
}
)
// Condition is falsy.
_also(
{
onTrueFlag: false,
onFalseFlag: false,
},
(flags) => {
const returnValue = _callIfTruthyWithReturn(
undefined,
(it) => {
expect(it).toBeFalsy()
flags.onTrueFlag = false
return "true"
},
() => {
flags.onFalseFlag = true
return "false"
}
)
expect(returnValue).toEqual("false")
expect(flags.onTrueFlag).toBeFalsy()
expect(flags.onFalseFlag).toBeTruthy()
}
)
This is the inverse of _callIfTruthy
. If the contextObject
is
falsy, then the lambda is executed & it
gets the type-safe non-null reference to contextObject
. Here's an example.
let executedIfNull = false
let executedIfUndefined = false
_callIfFalsy(undefined, () => {
executedIfUndefined = true
})
_callIfFalsy(null, () => {
executedIfNull = true
})
expect(executedIfUndefined).toBeTruthy()
expect(executedIfNull).toBeTruthy()
This is similar to _callIfTruthy
except that the first argument is a boolean
. Here's an example.
let flag = false
const fun = () => {
flag = true
}
_callIfTrue(false, fun)
expect(flag).toBeFalsy()
_callIfTrue(true, fun)
expect(flag).toBeTruthy()
This is the inverse of _callIfFalse
. Here's an example.
let flag = true
const fun = () => {
flag = false
}
_callIfFalse(true, fun)
expect(flag).toBeTruthy()
_callIfFalse(false, fun)
expect(flag).toBeFalsy()
This is similar to _callIfTruthyWithReturn
except that it accepts a boolean condition expression.
This is an interesting control flow expression. It is another way to represent an if statement
.
Three arguments need to be passed.
- The first is the condition variable which can evaluate to true or false.
- The 2nd is the lambda that is executed if the condition holds true. The return value of this lamba is returned by the expression.
- The 3rd argument is the lambda that's executed if the condition holds false. The return value of this lambda is returned by the expression.
Here's an example.
// Condition is true.
_also(
{
onTrueFlag: false,
onFalseFlag: false,
},
(flags) => {
const returnValue = _callIfTrueWithReturn(
true,
() => {
flags.onTrueFlag = true
return "true"
},
() => {
flags.onFalseFlag = false
return "false"
}
)
expect(returnValue).toEqual("true")
expect(flags.onTrueFlag).toBeTruthy()
expect(flags.onFalseFlag).toBeFalsy()
}
)
// Condition is false.
_also(
{
onTrueFlag: false,
onFalseFlag: false,
},
(flags) => {
const returnValue = _callIfTrueWithReturn(
false,
() => {
flags.onTrueFlag = false
return "true"
},
() => {
flags.onFalseFlag = true
return "false"
}
)
expect(returnValue).toEqual("false")
expect(flags.onTrueFlag).toBeFalsy()
expect(flags.onFalseFlag).toBeTruthy()
}
)
Avoid using null
or undefined
in your code by using Option
and None
and Some
. This is
similar to what you get in Rust. However, it is implemented using TypeScript discriminated unions
and an abstract class. A lot of the code in this library itself has been rewritten using this
pattern to avoid null
or undefined
resulting in much easier to reason about code.
Here's an example.
import { KeypressOption, _callIfSome } from "r3bl-ts-utils"
const onKeypress = (keypress: KeypressOption) => {
_callIfSome(keypress, (keypress) => {
if (keypress.matches("return")) returnPressed()
if (keypress.matches("downarrow")) downarrowPressed()
if (keypress.matches("uparrow")) uparrowPressed()
if (keypress.matches("space")) spacePressed()
if (keypress.matches("delete")) deletePressed()
if (keypress.matches("backspace")) deletePressed()
})
}
Here's an example of using a None
value in a useStateSafely
hook.
import { useStateSafely, Option } from "r3bl-ts-utils"
type KeypressOption = Option<Readonly<Keypress>>
const [keypress, setKeypress] = useStateSafely<KeypressOption>(Option.none()).asArray()
Here's an eample of using a Some
value in a useStateSafely
hook.
import { Optional, TimerTickFn, Option } from "r3bl-ts-utils"
let _onStartFn: Option<TimerTickFn> = Option.none()
function setOnStartFn(value: Optional<TimerTickFn>) {
_onStartFn = Option.create(value) // same as Option.some(value)
}
You can check to see whether an Option
value is Some
or None
by using the isSome
and
isNone
functions. These functions also act as user defined type guards
(via type predicate)
and will narrow the type of the Option
to None
/ Some
when used in an if
statement. Here's
an example.
{
keyPress.isSome() ? (
<Text color="cyan">{keyPress.value.toString()}</Text>
) : (
<Text color="red">!keyPress</Text>
)
}
This function is simply based on the debug!
macro in the Rust standard library. It is used to
pretty print the argument passed to it, along w/ a message. The argument passed is simply returned
by the function. Here's an example.
import { debug } from "r3bl-ts-utils"
const value = "some_value"
expect(debug("message", value)).toBe("some_value")
To use the timer utils, use the factory function createTimer()
which returns an object that
implements the Timer
interface. This interface and the tests are a great place to discover
the API surface. Here's an example of using this in a React function component that uses Hooks to
generate a CLI interface using ink.
import React, { FC } from "react"
import { Timer, createTimer } from "r3bl-ts-utils"
import { _also } from "r3bl-ts-utils"
import { _withRef, StateHook, useForceUpdateFn } from "r3bl-ts-utils"
import { Text } from "ink"
export const ComponentUsingTimer: FC = () => {
const myTimerRef: ReactRef<Timer> = React.useRef<Timer>()
/* ⚡ From React Hook utils. */
const [count, setCount]: StateHook<number> = React.useState<number>(0)
const forceUpdateFn = useForceUpdateFn()
React.useEffect(effectToStartTimer, [] /* componentDidMount */)
React.useEffect(effectToCheckStoppingTimer)
return render()
// Define the functions used above.
function effectToStartTimer() {
/* ⚡ From scope functions. */
const timer = _also(createTimer(TimerConfig.name, TimerConfig.updateIntervalMs), (it) => {
myTimerRef.current = it
it.onTick = tickFn
}).startTicking()
return () => {
if (timer.isStarted) timer.stopTicking()
DEBUG && console.log(`😵 unmount`, myTimerRef.current)
}
}
function tickFn(timer: Timer): void {
setCount(timer.counter.value)
DEBUG && console.log(`"${timer.name}"`, timer.isStarted, timer.counter.value)
}
function effectToCheckStoppingTimer() {
_withRef(myTimerRef, (timer) => {
if (timer.isStarted) {
if (timer.counter.value >= TimerConfig.maxCounter) {
timer.stopTicking()
forceUpdateFn() // Force a re-render after timer has stopped, to show the skull.
}
}
})
}
function render() {
return (
<Text color={"green"}>
[{count} tests passed]
{showSkullIfTimerIsStopped()}
</Text>
)
}
function showSkullIfTimerIsStopped() {
return !myTimerRef.current?.isStarted ? "💀" : null
}
}
const TimerConfig = {
name: "Count to 5 timer",
updateIntervalMs: 1000,
maxCounter: 5,
}
Cache utils provides an object that implements the Cache
interface, that is created by the
createCache()
factory function. You can specify the max size of the cache, and even declare which
eviction policy it should use. You can also provide a function that generates a value for a given
key if it doesn't exist in the cache. Here's an example of how to use this class.
import { createCache, _repeat } from "r3bl-ts-utils"
test("Cache eviction policy least-recently-used works", () => {
let populateFn = (arg: string): string => arg + "_test"
const cache = createCache<string, string>("test", 2, "least-recently-used")
cache.get("foo", populateFn)
cache.get("bar", populateFn)
cache.get("baz", populateFn)
expect(cache.size).toEqual(2)
expect(cache.contains("foo")).toBeFalsy()
expect(cache.contains("bar")).toBeTruthy()
expect(cache.contains("baz")).toBeTruthy()
})
test("Cache eviction policy least-frequently-used works", () => {
let populateFn = (arg: string): string => arg + "_test"
const cache = createCache<string, string>("test", 2, "least-frequently-used")
_repeat(3, () => cache.get("foo", populateFn))
_repeat(2, () => cache.get("bar", populateFn))
cache.get("baz", populateFn)
expect(cache.size).toEqual(2)
expect(cache.contains("foo")).toBeTruthy()
expect(cache.contains("bar")).toBeTruthy()
expect(cache.contains("baz")).toBeFalsy()
})
An async version of ComputeValueForKeyFn
is also provided, aptly named
ComputeValueForKeyAsyncFn
. Here's an example of how to use this async form.
import { createCache } from "r3bl-ts-utils"
test("can getAndComputeIfAbsentAsync from Cache", async () => {
let executionCount = 0
const populateAsyncFn = (arg: string): Promise<string> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(arg + "_test")
executionCount++
}, 10)
})
}
const cache = createCache<string, string>("test", 2, "least-frequently-used")
// Cache miss for "foo" -> insert.
await expect(cache.getAndComputeIfAbsentAsync("foo", populateAsyncFn)).resolves.toEqual(
"foo_test"
)
// Cache hit for "foo".
expect(cache.contains("foo")).toBeTruthy()
await expect(cache.getAndComputeIfAbsentAsync("foo", populateAsyncFn)).resolves.toEqual(
"foo_test"
)
expect(executionCount).toBe(1)
})
The TextColor
class provides a DSL (domain specific language) that makes it trivial to generate
reusable styles to apply ANSI colors to strings. This is very useful when making CLI (command line
interface) apps or colorful console log outputs. For just color colorful console output, you can
check out the next section on Colorized console. You can browse the source
here.
This serves as TypeScript replacements of both
colors.js
andchalk
libraries (both of which are written in JavaScript).
Here is a simple example that colorizes a string.
import { TextColor } from "r3bl-ts-utils"
const expected: string = "�[47m�[31mtext�[39m�[49m"
const style = TextColor.builder.red.bgWhite.build()
expect(style("text")).toEqual(expected)
expect(style.applyFormatting("text")).toEqual(expected)
Here's similar functionality via a one liner.
import { TextColor } from "r3bl-ts-utils"
const formattedOutput = TextColor.builder.red.bgWhite.build()("text")
const expected: string = "�[47m�[31mtext�[39m�[49m"
expect(formattedOutput).toEqual(expected)
The figures
object provides a set of Unicode symbols that can be used to pretty print console /
terminal based output. Things like checkboxes, circles, arrows, etc. are provided for convenience.
And it doesn't rely on using emoji.
This serves as a TypeScript replacement of
figures
library (which is written in JavaScript).
Let's look at how to use this by example. You can browse the source here.
You can simply colorize existing console.log
and console.error
, like this using the default
styles that are provided in Styles
.
import { printHeader, Styles } from "r3bl-ts-utils"
printHeader(`Example 1`)
console.log(Styles.Primary(`Wrote file successfully. 👍`))
console.error(Styles.Secondary(`Failed to write file! ⛔`))
Styles.Primary(`Wrote file successfully. 👍`).consoleLog()
Styles.Secondary(`Failed to write file! ⛔`).consoleError()
To override on the default styles, here's an example.
Here's how you can use the ColorConsole
class to do more powerful things.
import { printHeader, Styles, ColorConsole } from "r3bl-ts-utils"
printHeader(`Example 2`)
const myColorConsole = ColorConsole.create((text) => colors.bold(colors.yellow(text)))
myColorConsole(/* text: */ `Start log output...`).consoleLog()
const count = 4
while (count-- > 0) {
ColorConsole.create(Styles.Primary)(`While loop output: ${count}`).consoleLogInPlace()
}
ColorConsole.create(Styles.Secondary)(/* text: */ `End log output...`).consoleLog(
/* prefixWithNewLine: */ true
)
Note that once created, using
create()
, you can simply call that instance w/out passing a method. This happens because thecreate()
factory method creates a newColorConsole
object, which also implements theColorConsoleIF
interface, which is callable) by providing a(text: string)
signature that binds to thecall(text: string)
method.
If you don't deviate from the Primary
and Secondary
styles, then you can simply use some default
ColorConsole
instances that have been created for you like this.
import { printHeader, Styles, ColorConsole, StyledColorConsole } from "r3bl-ts-utils"
import * as _ from "lodash"
printHeader(`Example 3`)
const data = { foo: "foo_value", bar: "bar_value" }
for (const key in data) {
StyledColorConsole.Primary(
Styles.Primary(key) + " -> " + Styles.Secondary(_.get(data, key))
).consoleLog()
}
This function simply puts the current async function to sleep and prints a spinner in console stdout. Here's an example of it in action (in a Node.js program).
import { printHeader, sleep } from "r3bl-ts-utils"
const main = async (): Promise<void> => {
printHeader(`Example 1 - spawn a child process to execute a command`)
await new SpawnCProcToRunLinuxCommandAndGetOutput().run()
await sleep(1000)
printHeader(`Example 2 - pipe process.stdin into child process command`)
await new SpawnCProcAndPipeStdinToLinuxCommand().run()
await sleep(1000)
printHeader(`Example 3 - pipe the output of one child process command into another one`)
await new SpawnCProcToPipeOutputOfOneLinuxCommandIntoAnother().run()
await sleep(1000)
printHeader(`Example 4 - replace the functionality of a fish shell script`)
await new SpawnCProcToReplaceFunctionalityOfFishScript().run()
await sleep(1000)
}
main().catch(console.error)
The following utility functions make it easier to work w/ React in general.
The following utility types and custom hooks make it easier to work w/ React function components and hooks.
The StateHook<T>
utility type makes it easy to describe the array returned by
React.useState<T>()
. Here's an example.
import { StateHook } from "r3bl-ts-utils"
export const MyFunctionalComponent: FC = () => {
const [count, setCount]: StateHook<number> = React.useState<number>(0)
/* snip */
}
The useForceUpdateFn()
is a custom hook that makes it easy for you to force a re-render of your
component (when there are no props or state changes to trigger them). Here's an example.
import { useForceUpdateFn } from "r3bl-ts-utils"
export const MyFunctionalComponent: FC = () => {
const forceUpdateFn = useForceUpdateFn()
React.useEffect(() => setTimeout(forceUpdateFn, 1000), [] /* componentDidMount. */)
return <h1>{Date.now()}</h1>
}
The _withRef()
function provides a slightly cleaner syntax to working w/
React.useRef().current
.
The current
property can be nullish
and while you can use
optional chaining
to access this property's value, it can be quite clunky to write this code. Here's a snippet.
const myTimerRef: ReactRef<Timer> = React.useRef<Timer>()
function showSkullIfTimerIsStopped() {
return !myTimerRef.current?.isStarted ? "💀" : null
}
When using _withRef()
you have to think about 2 arguments, the ref itself, and a lambda that you
want to execute that does something w/ the current
property of the ref, if its
truthy.
The following code snippet is an example of a lambda that operates on the current
property of the
myTimerRef
, which holds a Timer
object.
import { ReactRefReceiverFn, ReactRef, _withRef, useForceUpdateFn } from "r3bl-ts-utils"
import * as React from "react"
const myTimerRef: ReactRef<Timer> = React.useRef<Timer>()
function MyComponent() {
React.useEffect(() => _withRef(myTimerRef, checkIfTimerIsStopped))
return <h1>{!myTimerRef.current?.isStarted ? "✋" : "🏃"}</h1>
}
const checkIfTimerIsStopped: ReactRefReceiverFn<Timer> = (timer: Timer) => {
if (timer.isStarted) {
if (timer.counter.value >= TimerConfig.maxCounter) {
timer.stop()
forceUpdateFn() // Force a re-render after timer has stopped, to show the skull.
}
}
}
💡 Note that the
ReactRef<T>
utility type is also provided to make it easy to work w/ objects returned byReact.useRef<T>()
. Additionally,ReactRefReceiverFn<T>
is provided to make it easy to type the function lambda that is passed as a 2nd argument to_withRef()
.
A common pattern in composing React elements is taking an array and converting into a list of JSX elements. This also requires that a key be inserted in each of the rendered items. This is code that has to written repeatedly and this function aims to eliminate the need for that. Here's an example.
import { emptyArray, makeReactElementFromArray, RenderEachInputFn } from "r3bl-ts-utils"
import { Text } from "ink"
import React, { FC } from "react"
const Row_Instructions: FC = function (): JSX.Element {
const inputs = [
["blue", "Press Tab to focus next element"],
["blue", "Shift+Tab to focus previous element"],
["blue", "Esc to reset focus."],
["red", "Press q to exit"],
]
return makeReactElementFromArray(inputs, renderToText)
function renderToText(line: string[], id: number): JSX.Element {
return (
<Text color={line[0]} key={id}>
{line[1]}
</Text>
)
}
}
When building user interfaces (UIs) for command line interface (CLI) apps it becomes necessary to
incorporate external (to React) async events that are generated by some event emitter (for eg, a
keypress event or some other async event that comes in from a process
or fs
object). To bring
these events into the React code, we would think to just use a useEffect()
hook somehow to do
this, but unfortunately it isn't that simple.
When writing custom hooks, that relay external async events (eg, happening in a listener that is
attached Node.js, or a database, etc.), it is important to keep in mind that callback functions that
are passed to useEffect()
can not directly call into the React function component 🧨!
🤔 This is a very subtle but important point to remember when figuring how to "relay" the non-React async event into the React function component world (via a custom hook that you're writing). When using "normal" React (host components in a browser) we don't get to see this because the code that interfaces with the browser's DOM is provided to us. So we are used to attaching a callback function to an
onclick
props of abutton
in React, and it would just work 🧙. However, under the covers this host component is taking care of interfacing w/ a DOM async event emitter to invoke callback function viaaddEventListener
and doing something similar to this hook.
It might be tempting to simply pass a React-function-component-callback function to a hook, which then passes it on to an external event emitter listener 😈. When the listener is then executed by the external event emitter (which is not a React function component) then problems will arise! The state information that this React-function-component-callback will get will be wrong.
The way to get around this issue is to do the following:
- Introduce an intermediate state variable (via
useState()
hook). - Have your external event listener use the setter for this state variable to let your hook know that some external event just came in.
- Use
useEffect()
and pass the getter for this state variable as a dependency to a function that gets called when this state variable changes. This function will be able to then run the React-function-component-callback that is passed into the hook to begin with.
💡 Please read about this in much more detail in this developerlife.com article on hooks.
Instead of doing all of these steps manually, you can just use useEventEmitter()
hook! 🎉 Here's
an example.
import { useEventEmitter } from "r3bl-ts-utils"
const emitter = new EventEmitter()
const eventName = "my-event-name"
const onEventName: HandleNodeKeypressFn = (input: string, key: ReadlineKey) =>
_let(createFromKeypress(key, input), (keyPress) => {
// Using keypress, determine which function to call inside React UI. Or maybe dispatch a Redux
// action.
})
useEventEmitter(emitter, eventName, onEventName, { isActive: true })
This launches a CLI app. This is the "bootloader" equivalent for a CLI app.
💡 If you have any event listeners attached (eg, using
useKeyboard()
, oruseNodeKeypress()
) then your app won't exit if you don't callLifecycleHelper.fireExit()
.
Usage example:
const App: FC = () => {
const createShortcutsFn = (): ShortcutToActionMap =>
_also(createNewShortcutToActionMap(), (map) => map.set("q", LifecycleHelper.fireExit))
useKeyboardWithMapCached(createShortcutsFn)
return <Text>"Hello"</Text>
}
inkCLIAppMainFn(() => {
const args = processCommandLineArgs()
return createInkApp(args)
}).catch(console.error)
This utility class is intended to be used in command line interface apps that run in a terminal (in
Node.js). However, it can be used in browsers as well. The idea w/ this class is to fire a global
start
event when the CLI app container has started and is warm. And to fire a exit
event when
the container is in the process of initiating its exit (before process.exit()
is called). Your
code can attach listeners to these events. And you have to wire this class into the key lifecycle
events of your app. Here's an example.
//#region main().
type MainParams = "node-keypress" | "ink-compat"
export const main = (arg: MainParams) => {
const instance = render(<App arg={arg} />)
LifecycleHelper.addExitListener(() => {
instance
.waitUntilExit()
.then(() => {
console.log("Exiting ink")
})
.catch(() => {
console.error("Problem with exiting ink")
})
TimerRegistry.killAll()
instance.unmount()
})
}
//#endregion
The useClock()
custom hook can be used start a clock that ticks every 1 second and updates the
state. The hook returns a number
that can be used to render a UI in React.
Here's an example.
/** App function component. */
export const appFn: FC<{ name: string }> = ({ name }) => render(runHooks(name))
function runHooks(name: string): LocalVars {
const time = useClock()
return {
time,
}
}
interface LocalVars {
time: number
}
function render(locals: LocalVars) {
const { time } = locals
return <Text>{new Date(time).toLocaleTimeString()}</Text>
}
The hook does clean up after itself (it will kill its internal Timer
when it's enclosing component
is unmounted). You can also call TimerRegistry.killAll()
when you exit your app to make sure.
Here's an example of doing this for a command line interface app built using Ink.
_also(render(createElement(appFn, { name: !name ? "Stranger" : name })), (ink) => {
ink
.waitUntilExit()
.then(() => {
TimerRegistry.killAll()
})
.catch(() => {
console.error("Problem with exiting ink")
})
})
This is very similar to useClock()
except that it takes a delayMs
argument that sets the delay
that is used to tick the clock. It returns an object that contains both the locale formatted time
string and the time in ms.
The useTTYSize()
custom hook can be used to determine what the width and height of a TTY is, in
CLI apps that use Ink. It uses Node.js stdout stream to get this information.
Here's an example. When you resize the terminal window, it will re-render the component and display the current terminal size.
import React from "react"
import { render, Text } from "ink"
import { useTTYSize, TTYSize } from "r3bl-ts-utils"
function Application() {
const size: TTYSize = useTTYSize()
return (
<Text>
{size.rows}×{size.columns}
</Text>
)
}
const { unmount } = render(<Application />)
setTimeout(() => unmount(), 30_000)
The useStateSafely()
custom hook behaves like the "normal" useState()
hook with the differences
being that difference it won't update the state if its enclosing component has been unmounted. It
also returns an object if you want (or an array if you like). This is useful in CLI (command line
interface) apps where a component could be unmounted (eg, via a keyboard shortcut to switch to a
different tab or even exit the app). Here's an example of how to use it.
import { _let } from "r3bl-ts-utils"
const UseTextInput: FC = () => _let(useStateSafely(""), createComponent)
// To get an array, call `useStateSafely("").toArray()`.
const createComponent = ({ value: query, setValue: setQuery }): ReactElement => (
<Box flexDirection="column">
<Box flexDirection="row" marginRight={1}>
<Text>Enter your query: </Text>
<TextInput value={query} onChange={setQuery} />
</Box>
<Text>You typed: {TextColor.builder.rainbow.build()(query)}</Text>
</Box>
)
This hook is an alternative to the useInput()
hook from ink
. Both are meant to capture key
presses from a terminal. It uses Node.js readline package which emits keypress
events which are
different from the data
events that useInput()
relies on. This means that a few more key events
are available using this approach which is useful for command line interface (CLI) apps that rely on
keyboard shortcuts for doing most things. However, the limitations on what key events a TTY can even
get in Node.js is limited by Node.js itself. For this reason we hope to provide our own native Rust
based keypress input event handling plugin for Node.js in the future
(here's the issue).
There is one side effect that happens when this hook in addition to useInput()
, which comes from
how one actually uses keypress
events and the other uses data
events, which is that they both
manage the raw mode of the TTY stdin stream (from Node.js). And this can lead to CLI apps quitting
unexpectedly and issues w/ keyboard focus management. In order to solve this, there are 2
approaches:
- You can use ink compat versions of all the hooks in this section and bypass
useNodeKeypress()
all together. - You can wrap your main app w/ a
Provider
that configures Ink correctly and lets it know thatuseNodeKeypress()
will be managing raw mode for the terminal. The easiest way to do this is to wrap your entire application w/UseKeyboardWrapper
component.
⚡ For a full example, please check out /src/experimental/confirm-input/node-keypress.tsx.
💡 Ink has a hook that is supposed to be used to get user input from the keyboard called
useInput
, which comes from theink
package.TextInput
is built on top of this hook.TextInput
comes from the npm packageink-text-input
.The
useKeyboard
hook does not useuseInput
from Ink. It does the following things.
- It will set raw mode to true when used.
- It will turn raw mode to false when unmounted.
- It uses Node.js readline keypress events to better detect key presses (instead of
useInput
).The problem w/
TextInput
usinguseInput
and then turning raw mode to off when focus changes onTextInput
simply causes the Node.js process to exit, since there are no active listeners attached to it 🤯.🤔 To mitigate this problem one approach could to be call
useInput(noop)
somewhere in the component that includesTextInput
.🤔 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:<StdinContext.Provider value={{ stdin: process.stdin, setRawMode: noop, isRawModeSupported: true, internal_exitOnCtrlC: false, }} > <App /> </StdinContext.Provider>🧙 or simply:
<UseKeyboardWrapper> <App /> </UseKeyboardWrapper>
The useKeyboard()
custom hook can be used to attach a function that responds to key presses in the
terminal.
- The
Keypress
class represents a single keypress (eg: a, backspace, or ctrl+k). - The
KeyboardInputHandlerFn
is a function that does something meaningful w/ aKeypress
object (as it comes in from the terminal when the user presses keys). - The
Keypress
class creates immutable objects so it safe to use w/out having to worry about references or memory leaking or having strange side effects when doing keyboard input handling using this.
🧙 The
Keypress
class works with Node.js readlinekeypress
events, and there's an Ink compatibility version as well. Ink doesn't really handle some input events correctly, which is what prompted the creation of this. To use the Ink compatible version of this hook, you can useuseKeyboardCompatInk()
.
Here's an example.
import { KeyboardInputHandlerFn, Keypress, useKeyboard } from "r3bl-ts-utils"
const UseFocusExample: FC = () => {
const { keyPress, inRawMode } = useKeyboard(
onKeyPress.bind({ app: useApp(), focusManager: useFocusManager() })
)
return (
<UseKeyboardWrapper>
<Box flexDirection="column">
{keyPress && <Row_Debug keyPressed={keyPress?.key} inputPressed={keyPress?.input} />}
<Row_Instructions />
<Row_FocusableItems />
</Box>
</UseKeyboardWrapper>
)
}
const onKeyPress: KeyboardInputHandlerFn = (
this: { app: AppContextProps; focusManager: FocusContextProps },
userInputKeyPress: Keypress
) => {
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"))
}
This hook utilizes the useKeyboard()
hook and makes it really easy to enable keyboard handling for
CLI apps. Instead of providing the logic to match a "a keypress that the user typed" to a function
("action"), this hook takes care of all that. Instead, you can provide a map that declares the key
presses that should be matched in order to invoke an action. When combined w/ the
useMemo()
React hook, this also caches
this map which is expensive to re-create on every key press.
🧙 The
Keypress
class works with Node.js readlinekeypress
events, and there's an Ink compatibility version as well. Ink doesn't really handle some input events correctly, which is what prompted the creation of this. To use the Ink compatible version of this hook, you can useuseKeyboardCompatInkWithMap()
.
⚡ In cases when you don't need the map to be generated every time your functional component is rendered, you can cache the results using this variant of the hook
uesKeyboardWithMapCached()
.
Here's an example.
import {
_also,
_let,
createNewShortcutToActionMap,
TextColor,
useKeyboardWithMap,
Keypress,
} from "r3bl-ts-utils"
//#region Main function component.
const functionComponent: FC = () => render(runHooks())
//#endregion
//#region runHooks.
interface RenderContext {
keyPress: Keypress | undefined
inRawMode: boolean
}
const runHooks = (): RenderContext => {
const app = useApp()
const createShortcuts = () =>
_also(createNewShortcutToActionMap(), (map) =>
map.set("q", app.exit).set("x", app.exit).set("ctrl+q", app.exit).set("ctrl+x", app.exit)
)
return _let(useMemo(createShortcuts, []), useKeyboardWithMap)
}
//#endregion
//#region UI.
const render = (ctx: RenderContext) => {
const { keyPress, inRawMode } = ctx
return (
<UseKeyboardWrapper>
<Box flexDirection="column">
{keyPress && <Row_Debug inRawMode={inRawMode} keyPress={keyPress.toString()} />}
<Text>{TextColor.builder.rainbow.build()("Your example goes here!")}</Text>
</Box>
</UseKeyboardWrapper>
)
}
const Row_Debug: FC<{ inRawMode: boolean; keyPress: string | undefined }> = ({
keyPress,
inRawMode,
}): JSX.Element =>
inRawMode ? (
<Text color="magenta">keyPress: {keyPress}</Text>
) : (
<Text color="gray">keyb disabled</Text>
)
//#endregion
ink.render(createElement(functionComponent))
This is even easier to use than the previous one. You don't even have to call
useMemo()
. Here's a simplification of the runHooks()
function from the example above.
🧙 The
Keypress
class works with Node.js readlinekeypress
events, and there's an Ink compatibility version as well. Ink doesn't really handle some input events correctly, which is what prompted the creation of this. To use the Ink compatible version of this hook, you can useuseKeyboardCompatInkWithMapCached()
.
import { useKeyboardWithMapCached } from "r3bl-ts-utils"
const runHooks = (): RenderContext => {
const app = useApp()
const createShortcuts = () =>
_also(createNewShortcutToActionMap(), (map) =>
map.set("q", app.exit).set("x", app.exit).set("ctrl+q", app.exit).set("ctrl+x", app.exit)
)
return useKeyboardWithMapCached(createShortcuts)
}
To simplify the management of 6 variants of the useKeyboard
hook, this builder provides a succinct
way of creating any of them. And it supports testing mode as well! You can browse the source code
and tests to see how to use this.
This UI component allows the user to input a single "y" or "n" response. And then submit that w/ pressing the enter key. A default value can be provided when the user just presses enter. Also only the "y" or "n" keys can be entered, no other alphanumeric characters will be accepted. Backspace and delete can also be used to clear the selection out. Here's an example of how it can be used.
import { ConfirmInput } from "r3bl-ts-utils"
const UnicornQuestion: FC<InternalProps> = ({ ctx }) => {
const [text, setText] = ctx.answer.asArray()
const onSubmit = (answer: boolean) => {
setText(answer ? "You love unicorns :)" : "You don't like unicorns :(")
}
return (
<Box flexDirection="column">
<Text>Do you like unicorns? (Y/n)</Text>
<ConfirmInput
placeholderBeforeSubmit="Type y/n, then press enter to submit"
placeholderAfterSubmit="Thank you"
defaultValue={false}
onSubmit={onSubmit}
/>
<Text>Your answer: {text}</Text>
</Box>
)
}
This UI component allows the user to select one or more items from a list of displayed items. Single and multiple selection modes are supported. Scrolling of really long inputs is also supported.
Please don't forget to wrap this component w/ the UseKeyboardWrapper
to maintain compatibility w/
Ink's useInput()
since this UI component uses useKeyboard()
. Here is the simplest way to use
this component.
🧙 A TypeScript version of
figures
npm package is included here. This became necessary since there are issues supporting TypeScript withfigures
(andchalk
, which is whyTextColor
is provided above).
const App: FC<{ items: ListItem[] }> = ({ items }) => {
const [selection, setSelection] = useStateSafely<undefined | ListItem[]>(undefined).asArray()
const [hasFocus, setHasFocus] = useStateSafely(true).asArray()
const selectionStr = selection
? "selection=[" + selection?.map(({ label }) => label).join(", ") + "]"
: "empty-selection"
return (
<UseKeyboardWrapper>
<Box flexDirection="column">
<Text color="gray">{selectionStr}</Text>
<Text>
{hasFocus
? TextColor.builder.green.build()("hasFocus")
: TextColor.builder.red.build()("!hasFocus")}
</Text>
<MultiSelectInput items={items} hasFocus={hasFocus} onSubmit={onSubmit} />
</Box>
</UseKeyboardWrapper>
)
function onSubmit(selectedItems: ListItem[]) {
setSelection(selectedItems)
setHasFocus(false)
}
}
The npm package contains the build
and src
folder contents. This is declared in the
package.json
entry of files
. There's an .npmignore
file as well, which contains a list of
exclusions from the npm package, but that only contains the .idea
folder (which is where JetBrains
IDEs store their project information).
Here are some good references for this:
files
array inpackage.json
- The files in this array are bundled into the npm package by default.- Best practices for what to include in a npm package.
Additionally, the index.ts
just re-exports all the exports of the files that actually contain
useful source code. In this case, the exports from kotlin-lang-utils.ts
, color-console-utils.ts
,
and misc-lang-utils.ts
are all just re-exported by index.ts
. This should make imports more
manageable for people who use this package. Also, users can simply import the whole thing into a
namespace of their choosing to prevent any collisions. For eg, it is possible to use this import
statement to put all the package symbols in a custom namespace like so.
import * as mynamespace from "r3bl-ts-utils"
mynamespace.StyledColorConsole.Primary("log output").consoleLog()
mynamespace.sleep(1000)
Here are some good references for this:
Here are the basic scripts that need to be used during development.
npm run build
- Build the project.npm run format
- Reformat the source code usingprettier
.npm run test
- Run all the tests (usingjest
).
⚠ Make sure that you are logged into your npmjs.org account using
npm login
before publishing.
Run npm publish
- This will publish your package to npm after running the following scripts.
npm run prepare
- This builds the package. It is run after the package is packed, published, and after its installed.npm run prepublishOnly
- This runs all the tests in the package.
Notes on
npm publish
.
- You can pass
--dry-run
as an option to perform all the steps actually publishing it to npm. The output fromnpm publish --dry-run
is very useful as it will show you exactly which files actually end up in your npm package. You can add or remove things to the.npmignore
file based on what you see here.- Once a package is published w/ a given name and version, it can never be used again (even if its removed w/
npm unpublish
). This has to be done from the command line. You can learn more about un-publishing a version or an entire package here.- More info on
npm publish
.
⚠ Make sure that you are logged into your npmjs.org account using
npm login
before publishing.
-
First run
npm version patch
- Make sure your git working directory is clean before running this. Run this in a package directory to bump the version and write the new data back topackage.json
,package-lock.json
. This will also kick off the following scripts in the given order. ⚠ Do not run the following scripts. -
npm run preversion
- This runs the tests. -
npm run version
- This just reformats the code and adds any new to git. -
After this step, npm automatically creates a git commit and a tag.
-
npm run postversion
- This pushes all the new commit and tag. -
Finally run
npm publish
to publish it to npm, since all the changes that have been made so far are just local.
Notes on
npm version
.
- More info on
npm version
.- Instead of
patch
you can also chooseminor
,major
, etc. You can also pass the new version string explicitly as one of the arguments to this command, eg:npm version 2.0 major
.
- File watchers added to run
doctoc
,prettier
on save for MD and TS files. - Run configuration is provided to run all tests and watch them called
Run all tests (watch)
. - Copyright configuration (to apply Apache 2.0 license) added for all the source files.
settings.json
is provided to allow Jest tests to be run automatically using this Jest extension.launch.json
is provided to allow tests to be run and debug in VSCode.