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";
139 changes: 139 additions & 0 deletions cli/spinner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

// TODO(kt3k): Write test when pty is supported in Deno
// See: https://github.com/denoland/deno/issues/3994

const encoder = new TextEncoder();

const LINE_CLEAR = encoder.encode("\r\u001b[K"); // From cli/prompt_secret.ts
const COLOR_RESET = "\u001b[0m";
const DEFAULT_INTERVAL = 75;
const DEFAULT_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

// This is a hack to allow us to use the same type for both the color name and an ANSI escape code.
// ref: https://github.com/microsoft/TypeScript/issues/29729#issuecomment-460346421
// deno-lint-ignore ban-types
type Ansi = string & {};
type Color =
| "black"
| "red"
| "green"
| "yellow"
| "blue"
| "magenta"
| "cyan"
| "white"
| "gray"
| Ansi;

const COLORS: Record<Color, string> = {
black: "\u001b[30m",
red: "\u001b[31m",
green: "\u001b[32m",
yellow: "\u001b[33m",
blue: "\u001b[34m",
magenta: "\u001b[35m",
cyan: "\u001b[36m",
white: "\u001b[37m",
gray: "\u001b[90m",
};

/** Options for {@linkcode Spinner}. */
export interface SpinnerOptions {
/**
* The sequence of characters to be iterated through for animation.
*
* @default {["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]}
*/
spinner?: string[];
/** The message to display next to the spinner. */
message?: string;
/** The time between each frame of 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.

It would be helpful to include the unit of time here (milliseconds) — otherwise, the user must review the implementation to understand. For example:

- The time between each frame of the spinner.
+ The time (milliseconds) between each frame of the spinner.

*
* @default {75}
*/
interval?: number;
/** The color of the spinner. Defaults to the default terminal color. */
color?: Color;
}

/**
* A spinner that can be used to indicate that something is loading.
*/
export class Spinner {
#spinner: string[];
#message: string;
#interval: number;
#color?: Color;
#intervalId: number | undefined;
#active = false;

/**
* Creates a new spinner.
*
* @example
* ```ts
* import { Spinner } from "https://deno.land/std@$STD_VERSION/cli/spinner.ts";
*
* const spinner = new Spinner({ message: "Loading..." });
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
* ```
*/
constructor(options?: SpinnerOptions) {
this.#spinner = options?.spinner ?? DEFAULT_SPINNER;
this.#message = options?.message ?? "";
this.#interval = options?.interval ?? DEFAULT_INTERVAL;
this.#color = options?.color ? COLORS[options.color] : undefined;
}

/**
* Starts the spinner.
*
* @example
* ```ts
* import { Spinner } from "https://deno.land/std@$STD_VERSION/cli/spinner.ts";
*
* const spinner = new Spinner({ message: "Loading..." });
* spinner.start();
* ```
*/
start() {
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
if (this.#active || Deno.stdout.writable.locked) return;
this.#active = true;
let i = 0;
const color = this.#color ?? "";

// Updates the spinner after the given interval.
const updateFrame = () => {
Deno.stdout.writeSync(LINE_CLEAR);
const frame = encoder.encode(
color + this.#spinner[i] + COLOR_RESET + " " + this.#message,
);
Deno.stdout.writeSync(frame);
i = (i + 1) % this.#spinner.length;
};
this.#intervalId = setInterval(updateFrame, this.#interval);
}
/**
* Stops the spinner.
*
* @example
* ```ts
* import { Spinner } from "https://deno.land/std@$STD_VERSION/cli/spinner.ts";
*
* const spinner = new Spinner({ message: "Loading..." });
* spinner.start();
*
* setTimeout(() => {
* spinner.stop();
* console.log("Finished loading!");
* }, 3000);
* ```
*/
stop() {
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
if (this.#intervalId && this.#active) {
clearInterval(this.#intervalId);
Deno.stdout.writeSync(LINE_CLEAR); // Clear the current line
this.#active = false;
}
}
}