Skip to content
This repository has been archived by the owner on Jul 12, 2020. It is now read-only.

An elegant terminal experience that's simple to use

License

Notifications You must be signed in to change notification settings

MarcusCemes/dynamic-terminal

Repository files navigation

⛔ Deprecation notice

As of 2020-07-11, this package is officially no longer maintained.

The terminal is an old beast and shouldn't be pushed to far. This was an educational project, but there are too many edge cases and it's easy to break. If you would like an alternative, try Ink!

Dynamic Terminal

Create an efficient dynamic terminal experience
blessed is too much, log-update is too little

An example of usage

Blame the GIF image for the stuttering

Features

  • Fast - it repaints only what's changed since the last render
  • 🔥 Multithreaded - all work is offloaded to another thread
  • 📦 Attempts to compensate for terminal resizing and text wrapping
  • ✂️ Cross platform - Tested on Windows, macOS and Linux
  • 😊 Easy - Just pass a string, DT will do the rest


Quick Links
WhyGetting startedUsageLine ObjectAPITypescript

Why

I loved the idea of Listr, it creates a beautiful dynamic terminal output. However, the library focusses on simplifying the task scheduling process, and doesn't let you change the colours of the spinner for example, these are hard coded in to the library. Another annoyance is that you can't modify the task message when completed!

This began as an experiment, to see whether it could be feasibly done. I could take care of the task concurrency process, and wanted something that would only focus on updating the view. Something like React, that let's you design the logic, and tries to keep the view rendered as efficiently as possible.

Of course there's Ink and DraftLog, and the good old fashioned log-update, but most of these libraries replace the entire contents of the screen. This was another issue that I had, on some terminals large screen paints can cause annoying flickering, while also greatly decreasing the efficiency of asciinema and svg-term.

I wanted to create a simple solution. My idea was to have an array of lines represented as Javascript objects, these can be passed down to tasks by reference. They keep they object up to date, and DT keeps the terminal screen up to date, by only "painting" over portions of the screen that needs to be changed. Just to make my life difficult, I also decided to add a super simple way of adding a spinner, support for ANSI colour codes, text-wrapping and terminal resizing compensation in between renders 😊.

Getting started

Prerequisites

Dynamic Terminal was written in Javascript, and only works with Node.js. If you use something else, it's not worth the effort or the loss in efficiency.

It's designed for server-side CLI tools, I wouldn't trust it for anything that's production based. Bear in mind that old terminals might freak out, and stdout redirection will probably result in garbled output. It might be a good idea to detect if the output is TTY, and use traditional logging instead that doesn't mess with the cursor.

📦 Project installation

Open up a terminal session in your project folder and execute:

$ npm install --production dynamic-terminal

This installs dynamic-terminal locally in your project. It's as easy as that.

Usage

To use Dynamic Terminal, you can import the module using the new ES6 import syntax, or use old fashioned require destructuring. The imported class can be used to manage the worker.

import { DynamicTerminal } from "dynamic-terminal"; // Typescript
const { DynamicTerminal } = require("dynamic-terminal"); // legacy

const dt = new DynamicTerminal();

When the DynamicTerminal class is instantiated, a new worker is automatically spawned. If it is destroyed, you will have to restart it with the .startWorker() function.

Promises

All functions that communicate with the worker, such as start, update, stop, return ES6 Promises. It is not possible to remain synchronous, as the class has to communicate with the worker which is a separate process. Waiting is not necessary, but can be safer sometimes.

These Promises never reject and the reason of failure can be queried by accessing the lastError property on the class itself:

if ((await dt.start()) === false) console.error(dt.lastError);

Promises will automatically resolve after 10 seconds to prevent endless hanging

Starting a new terminal session

A terminal session is a re-writable piece of history in the terminal. When you start a new terminal session, you may write, overwrite and clear anything that was displayed during that session.

await dt.start();

The rest of the Dynamic Terminal functions may now be used.

Writing to a terminal session

// Simple usage
dt.update("I'm doing stuff\nCheck back later...");

// Advanced usage, see below
const lines = [{ text: "I'm doing stuff", indent: 2 }, { text: "Check back later....", indent: 2 }];
dt.update(lines);

This will replace anything that was written previously

Stopping a terminal session

When stopping a terminal session, you may to choose to keep (commit) or erase the session, effectively clearing everything that was written and returning the cursor to the previous position.

if (success) {
  await dt.stop(true); // commit the text to screen
} else {
  await dt.stop(false); // erase the session
}

Destroying the session

This will effectively kill the worker. This is important if you would like to exit from your application gracefully by exhausting the Node.js event loop of work.

By not destroying the session, Node.js will be unable to quit without calling process.exit();

if (!dt.destroy()) process.exit(); // Returns a boolean

The Line Object

The Line object is the preferred way of providing text data to Dynamic Terminal. The best way to update the screen is to provide an array of Line objects to the update function. These Line objects can be distributed throughout your program to be updated by reference. You can then push the changes to Dynamic Terminal whenever you like.

Despite the name, a line object can actually span several lines. It will be wrapped automatically and split on any \n new lines in the text property.

const lines = [{ text: "" }, { text: "" }];
const updater = setInterval(() => dt.update(lines), 200);

await Promise.all(runTaskOne(lines[0]), runTaskTwo(lines[1]));

clearInterval(updater);

The task functions can do whatever they like with their line, updates get sent every 200ms

Line objects can have three different properties:

  • text {string} The text to display. Will be split if text-wrapping would occur
  • indent {number} The indentation level, this will be coppied over split Line objects
  • force {boolean} Repaint the entire line instead of just repainting portions.

Spinners

Spinners are cool. DT makes it easy to add one. The DT class exposes five constants that you can use. The spinner is the only dynamic element, it is a placeholder (currently _*_) that is replaced with a spinner frame upon each render.

dt.update((DynamicTerminal.SPINNER = " Hold on... I'm working!"));

This will prepend a pretty cyan spinner! The colour can be changed with the config

Note: The constants are static properties, so they can be accessed through the imported class, and not through an instantiated class.

The other constants are TICK, CROSS, TICK_RAW and CROSS_RAW. The RAW versions are unicode symbols, while the non-RAW versions are also coloured green and red.

API

This documentation uses the Typescript syntax. Dynamic Terminal also has full Typescript typings bundled.

Class Methods
startstopdestroyupdateappendstartWorkerforceRendergetRenderQueue

DynamicTerminal [class]

The main class that is used to interact with the worker. This acts as a controller, as well as exposing a few useful properties.

Static Properties

  • SPINNER {string} The spinner placeholder
  • TICK {string} A green tick
  • TICK_RAW {string} A colour-less tick
  • CROSS {string} A red cross
  • CROSS_RAW {string} A colour-less cross

Properties

  • lastError {string} The last error that occurred

Example

const { DynamicTerminal } = require("dynamic-terminal");
const myBetterTick = chalk.cyan(DynamicTerminal.TICK_RAW);
const dynamicTerminal = new DynamicTerminal();

dynamicTerminal.start( options: Options ): Promise<boolean>

Used to start a new terminal session. This will can be actively written to until it is stopped. Resolved into the completion success.

Options

The options object may be sniffed out through Typescript typings, nevertheless, here are the available properties:

  • disableInput {boolean} Sets the terminal to RAW mode, ignoring keypresses (these mess up the output). This will also intercept interrupt signals, so beware.
  • hideCursor {boolean} Hides the cursor in terminal, for a cleaner experience
  • spinnerColour {function} A function that will apply the colour codes to the raw spinner. See chalk.
  • updateFrequency {number} The interval in ms between renders when using a spinner. Affects the spin speed.
  • repaintOnResize {boolean} Repaint everything if terminal was resized, instead of gracefully trying to compensate for wrapped lines.

Example

await dynamicTerminal.start();

dynamicTerminal.stop( commit: boolean = true ): Promise<boolean>

Used to stop the terminal session and optionally commit the session to the terminal (keep the rendered text). Resolves into the completion success.

Example

await dynamicTerminal.stop(false);

dynamicTerminal.destroy( void ): boolean

Destroys the worker, allowing the Node.js event loop to quit gracefully. If a terminal session was active, it will be stopped and committed.

Example

dynamicTerminal.destroy();

dynamicTerminal.update( lines: string | string[] | Line | Line[] ): Promise<boolean>

Replaces the entire contents of the terminal session. If the provided argument is not of type Line[], it will be manually converted. Line objects will also be split if screen wrapping would occur, with indentation and forcing being preserved.

Example

await dynamicTerminal.update("line1line2");
await dynamicTerminal.update(myLineObjectsArray);

dynamicTerminal.append( lines: string | string[] | Line | Line[] ): Promise<boolean>

Appends to current open session. Quick and dirty, changes will be lost if the session is updated.

Example

await dynamicTerminal.append("Add a third line");

dynamicTerminal.startWorker( void ): void

Starts a new worker in case it was destroyed, or it is no longer connected for some abnormal reason.

Example

dynamicTerminal.startWorker();

dynamicTerminal.forceRender( force: boolean = false ): Promise<boolean>

Used to trigger a render of the screen, as the screen is only repainted if an update was pushed, or a SPINNER is present. Can get rid of artifacts.

The force paramter will ensure that the entire session is repainted, and not just trigger a change-detection assisteds render.

Example

await dynamicTerminal.forceRender(true);

dynamicTerminal.getRenderQueue( void ): Promise<Line[]>

Lost track of the terminal output? This will help you get back what you sent to the worker.

Example

const lines = await dynamicTerminal.getRenderQueue();
lines[0].text = DynamicTerminal.SPINNER + "I forgot to add a spinner...";
dynamicTerminal.update(lines);

private dynamicTerminal.send( data: any, timeout: number = 10000 ): Promise<any>

A private function that is used to send raw data to the worker, and resolve the response. The response data is recognized by generating a UUID for each message, the worker will include the same UUID in its response.

This serves as the base of the other functions, and should not be called directly.

Example

const response = await dynamicTerminal.send({
  cmd: "UPDATE",
  data: "I like to get my hands dirty."
});
if (response.error) console.error("Darn. I don't know how to use this.");

Render

The rendering process is efficient, and uses the help of the Change Algorithm to calculate which areas of the screen need to be repainted. The Change Algorithm will attempt to correctly slice out changes, while preserving the correct ANSI codes, and searching for them if necessary. If the terminal was resized since the last render, the previous render will be reflowed to compensate for any wrapped lines.

Bear in mind that complex ANSI operations will just not work... For that you may want to look into blessed.

The render function is called whenever a new update is pushed, or if a SPINNER is present in the render buffer, in which case it will be called with an interval. To change the interval frequency, see the options that can be passed to the worker.

Typescript

The entire project is written in Typescript, and all the public functions have typings applied to them.

Using an editor like Visual Studio Code, you can benefit from autocompletion, type-checking, object property hinting and helpful descriptions of config keys and functions as you type.

Even without modern editor, you can consult the generated *.d.ts typing files for correct API usage.

Debugging

Dynamic Terminal exposes two debug namespaces that you can tap into to monitor what's going on behind the scenes. These can be activated with the DEBUG environmental variable.

DTTRender

This debug namespace will give you a large readout of every screen render. This is useful when figuring out why the output might be incorrect. When this debug option is active, DynamicTerminal will not print any renders to the terminal.

DTTCommand

This prints live information about received data from the IPC channel, and what the thread is currently executing. Useful if the worker isn't responding to commands.

Example

set DEBUG=DTT*    // Windows
export DEBUG=DTT* // Linux

This will set the DEBUG environmental variable in a terminal session, enabling all debug namespaces for the DT Thread

Development

You may clone and build the module yourself. Dynamic Terminal uses Travis CI to run tests on all pushed changes, automatically deploying to npm when a significant operational change is made and all the tests have passed. Please make sure that your contributions pass tests before submitting a Pull Request, and that your commit messages follow the Conventional Commits specification.

Build Status - master    Build Status - develop

Built With

  • NodeJS - Powered by Chrome's V8 Javascript engine

Versioning

We use SemVer for versioning. For the versions available, see the tags on this repository.

Authors

License

This project is licensed under the Apache 2.0 License - see the LICENSE.md file for details