Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): command line spinner #3968

Merged
merged 12 commits into from
Dec 20, 2023
1 change: 1 addition & 0 deletions cli/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@

export * from "./parse_args.ts";
export * from "./prompt_secret.ts";
export * from './spinner.ts';
163 changes: 163 additions & 0 deletions cli/spinner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

const encoder = new TextEncoder();

const LINE_CLEAR = encoder.encode("\r\u001b[K"); // From cli/prompt_secret.ts
const COLOR_RESET = "\u001b[0m";
const DEFAULT_SPEED = 75;

/** The options for the spinner */
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
export interface SpinnerOptions {
/** The spinner to be animated */
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
spinner: string[];
/** The message to display next to the spinner */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/** The message to display next to the spinner */
/** The message to display next to the spinner. */

message?: string;
/** The speed of the spinner. Defaults to 75 */
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
speed?: number;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

speed sounds strange to me as lower number gives faster speed of spinner. How about internal? ref: https://github.com/sindresorhus/ora?tab=readme-ov-file#interval

/** The color of the spinner. Defaults to the default terminal color */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/** The color of the spinner. Defaults to the default terminal color */
/** The color of the spinner. Defaults to the default terminal color. */

color?: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems this needs to be ANSI color code. On the other hand, ora supports human-readable color names ('black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white' | 'gray'). I wonder which is the better design..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it definitely makes it easier to read and write quickly. I'll make it support both 👍

/** Whether to cleanup the output if the process is terminated. Defaults to true */
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
cleanup?: boolean;
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
}

/** The default spinners */
export const spinners = {
lines: ['-', '\\', '|', '/'],
binary: ['0000', '1000', '1100', '1110', '1111', '0111', '0011', '0001'],
dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
dots2: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'],
blink: ['◜', '◠', '◝', '◞', '◡', '◟'],
}

iuioiua marked this conversation as resolved.
Show resolved Hide resolved
/**
* A spinner that can be used to indicate that something is loading.
*/
export class Spinner {
#message: string | undefined;
#speed: number;
#spinner: string[];
#color: string | undefined;
#cleanup: boolean;

iuioiua marked this conversation as resolved.
Show resolved Hide resolved
#intervalId: number | undefined;

/**
* Creates a new spinner.
* @param message - The message to display next to the spinner.
* @param speed - The speed of the spinner.
* @param spinner - The spinner to use.
* @param color - The color of the spinner.
* @param cleanup - Whether to cleanup the output if the process is terminated.
*/
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
constructor(message: string | undefined, speed: number, spinner: string[], color: string | undefined, cleanup: boolean) {
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
this.#message = message;
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
this.#speed = speed;
this.#spinner = spinner;
this.#color = color;
this.#cleanup = cleanup;
}

/**
* Starts the spinner.
*/
start() {
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
let i = 0;
const message = this.#message ?? '';

const color = this.#color ?? '';

if (Deno.stdout.writable.locked) return;

// Updates the spinner after the given interval.
const updateFrame = () => {
Deno.stdout.writeSync(LINE_CLEAR);
let frame;
// If color was specified, add it to the frame. Otherwise, just use the spinner and message.
if (this.#color) frame = encoder.encode(color + this.#spinner[i] + COLOR_RESET + ' ' + message);
else frame = encoder.encode(this.#spinner[i] + ' ' + message);
Deno.stdout.writeSync(frame);
i = (i + 1) % this.#spinner.length;
}

this.#intervalId = setInterval(updateFrame, this.#speed);

// Cleanup if the process is terminated.
if (this.#cleanup) Deno.addSignalListener("SIGINT" , this.cleanup.bind(this));
}

/**
* Stops the spinner.
*/
stop() {
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
if (this.#intervalId) {
clearInterval(this.#intervalId);
Deno.stdout.writeSync(LINE_CLEAR); // Clear the current line
}
}

/**
* Stops the spinner and cleans up the output.
*/
cleanup() {
this.stop();
Deno.stdout.writeSync(encoder.encode("\n"));
}
}

/**
* Creates a spinner with the given options.
*
* Note: The spinner will _not_ start automatically.
* @param options - The options for the spinner.
* @returns The created spinner.
*
* @example
* ```ts
* const mySpinner = createSpinner({
* spinner: spinners.lineSpinner,
* message: 'Loading...',
* });
*
* mySpinner.start();
* ```
*
* You can also add custom spinners:
* ```ts
* const mySpinner = createSpinner({
* spinner: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
* message: 'Loading...',
* speed: 20
* });
*
* mySpinner.start();
* ```
*/
export function createSpinner({ message, speed = DEFAULT_SPEED, spinner, color, cleanup = true }: SpinnerOptions): Spinner {
return new Spinner(message, speed, spinner, color, cleanup);
}

iuioiua marked this conversation as resolved.
Show resolved Hide resolved
/**
* Creates a spinner with the given options and starts it.
*
* @param promise The async callback function
* @param spinner The spinner to use
* @returns The result of the promise
*
* @example
* ```ts
* const myPromise = () => new Promise((resolve) => setTimeout(resolve, 5000));
*
* const spinner = createSpinner({
* spinner: spinners.dots,
* message: 'Loading...',
* });
*
* spinnerPromise(myPromise, spinner).then(() => console.log('Done!'));
* ```
*/
export async function spinnerPromise<T>(promise: () => Promise<T>, spinner: Spinner): Promise<T> {
spinner.start();
const result = await promise();
spinner.stop();
return result;
}
Loading