UNPKG

@syncify/ansi

Version:

ANSI Colors, Symbols and TUI related terminal enchancements for Syncify.

2,161 lines (1,817 loc) 47.8 kB
/* -------------------------------------------- */ /* MESSAGE GENERATOR */ /* -------------------------------------------- */ import type { IssueContext, Prefixes } from './write'; import type { Ansis } from 'ansis'; import type { LiteralUnion } from 'type-fest'; import { Console } from 'node:console'; import process from 'node:process'; import update, { createLogUpdate } from 'log-update'; import { glue } from '@syncify/glue'; import { NIL, NWL, WSP } from './characters'; import { gray, lightGray, neonMagenta, neonTeal, redBright, white, whiteBright, yellowBright } from './colors'; import { Spinner } from './spinner'; import { DSH } from './symbols'; import { Tree } from './tree'; import { tsize } from './tsize'; import { Context, Dash, End, Header, Line, Multiline, Prefix, Top, Wrap } from './write'; /* -------------------------------------------- */ /* CONSOLE */ /* -------------------------------------------- */ export class Log extends Console { static get stdout () { return process.stdout; } static get stderr () { return process.stderr; } static update: typeof update = createLogUpdate(Log.stdout); constructor () { super(Log.stdout, Log.stderr); } write (message: string) { Log.stdout.write(message); } info (message: string, color = whiteBright): void { Log.stdout.write(Line(color(message.trim())) + '\n'); } dash (message: string, color = whiteBright) { Log.stdout.write(Dash(color(message)) + '\n'); } error (message: string): void { Log.stderr.write(Tree.red + redBright(message.trim()) + '\n'); } warn (message: string): void { Log.stderr.write(Tree.yellow + yellowBright(message.trim()) + '\n'); } header (message: string, color = whiteBright) { Log.stdout.write(Header(color(message.trim())) + '\n'); return this; } wrap (...input: (string[] | string | Ansis)[]) { const color = <Ansis>(typeof input[input.length - 1] === 'function' ? input.pop() : gray); Log.stdout.write(Wrap(input as string[], { color, firstLineTree: false }) + '\n'); return this; } tree (type?: LiteralUnion<'red' | 'yellow', string>) { Log.stdout.write((type === 'red' ? Tree.redTrim : type === 'yellow' ? Tree.yellowTrim : Tree.trim) + '\n'); return this; } break () { Log.stdout.write('\n\n'); return this; } }; /* -------------------------------------------- */ /* MESSAGE GENERATOR */ /* -------------------------------------------- */ export interface toString { /** * Clears the stack. Passing id entries will result in specific stack entries * being pruned, typically those with track identifiers. This can be used to * reset only certain stack entries. * * - `default: true` when `toString()` * - `default: false` when `Log()` */ clear?: boolean | string | string[]; /** * Clears specific stack entries, typically those with track * identifiers. This can be used to reset only certain stack entries. * * > Passing `true` to `clear` will override and ignore these references */ prune?: string | string[]; /** * Applies a slice of the current stack. The stack is preserved with * only the entries starting from the provided index being logged. * * @default 0 */ from?: number; /** * Whether or not to disable final `trimEnd()` on the stack entry. * * - `default: true` when `toString()` * - `default: false` when `Log()` */ trim?: boolean; /** * Wrap the stack in a specific color * * @default undefined */ color?: Ansis; } export interface TUIOptions { /** * The type of tree message to generate - This will * default the `Tree.line` to a specific color, meaning * the `.line()` will be output according to the type. * * @default 'info */ type?: LiteralUnion<'info' | 'warning' | 'error', string>; /** * Optionally provide an existing structure to build from. * * @default [] */ stack?: string[]; /** * Whether or not tree printing applies * * @default true */ tree?: boolean; /** * @deprecated Use stack instead. */ text?: string[]; } interface TUISpinner { /** * Whether or not spinner is active */ active: boolean; /** * The label to append */ label: string; /** * The interval timer */ interval: NodeJS.Timeout; /** * This stack index of spinner message */ index: number; /** * The spinner style * * @default 'brielle' */ style: 'brielle' | 'spinning' | 'material'; /** * Ansi Colour * * @default neonMagenta */ color: Ansis; /** * The log update method to use on spinner update. * * > `clear` (default) * > * > Uses `this.update.clear()` by default * * > `done` * > * > Uses `this.update.done()` when `toUpdate()` was issued on instance. */ stopOn: 'clear' | 'done' } type toStringParam = ( | [ toString? ] | [ ((message: string) => any) ] | [ toString, ((message: string) => any) ] ) interface TemplateOptions<ID = string> { /** * An identifier reference for updates. This is **required** and * must be provided. */ id: ID; /** * Whether or not the template is hidden. When `true` the template * message can be assigned but not be added to the stack, instead it * will lay dormant in the track store until the `Update` method * is triggered. * * @default false */ hidden?: boolean; /** * The color of the message, this will persist * across all updated applied, unless overwritten via `Update` */ color?: Ansis, /** * Whether or not input should apply inline insertion, no Tree * prefix is applied, content is inserted as-is. * * @example * _.Template('id', { insert: true }) * * // Assuming the current stack is: * [ '│ hello' ] * * // Later on when calling update, insert will behave like: * _.Update('id','bar baz qux') => [ '│ hello', 'bar baz qux' ] * */ insert?: boolean, /** * Whether or not to apply tree line dash, default to `├─` * * @default false */ dash?: boolean /** * Whether or not `Update` should use the `id` and pass it to `_.Prefix` * By default, this will be `false`. Pass a `string` to use instead of * `id`. * * @example * _.Template('random', { prefix: true }) * * // Later on when calling update, the output will use the id as prefixer * _.Update('random', 'a b c') => '│ random → some message' */ prefix?: boolean | string } interface TemplatePrivate extends TemplateOptions { /** * The stack index */ index: number; /** * Label */ label: string; /** * When `hidden` is `true` this will hold the message to be inserted */ message: string[]; } type Track = Map<string, TemplatePrivate> /** * Terminal User Interface * * Static string builder for constructing a console log that * will be printed to `stdout` or `stderr`. Maintains a stack * for updates and quick referencing along with methods for * composing output. */ export class Tui<Templates extends string = string> { /** * Maintain Store * * Optional store reference used to maintain different TUI * instances without variable assignment. */ // eslint-disable-next-line no-use-before-define static store: Map<string, Tui> = new Map(); /** * CLI Spinner instance */ private spin: TUISpinner = { active: false, index: NaN, label: NIL, color: neonMagenta, style: 'spinning', interval: null, stopOn: 'clear' }; /** * Store ID * * When TUI is created with a self-maintaining instance. * If this value is `null`, variable assignment instance was created. * * @default null */ private id: string = null; /** * The type of tree message to generate - This will * default the `Tree.line` to a specific color, meaning * the `.line()` will be output according to the type. * * > `info` * > * > Output will be coloured `white` and Tree lines will be gray. * * > `warning` * > * > Output will be coloured `yellowBright` and Tree lines will be yellow. * * > `error` * > * > Output will be coloured `red` and Tree lines will be red. * * @default 'info */ private type: LiteralUnion<'nil' | 'info' | 'warning' | 'error', string> = 'info'; /** * Stack entry track * * @default Map */ private track: Track = new Map(); /** * The Tree line color based on message type * * @default Tree.line */ private line: string; /** * The Tree trim color based on message type * * @default Tree.trim */ private trim: string; /** * The Tree dash color based on message type * * @default Tree.dash */ private dash: string; /** * Whether or not tree line prefixes apply * * @default true */ private tree: boolean = true; /** * Optionally provide an existing structure to build from. * * @default [] */ private stack?: string[]; /** * Lambda functions */ private lamdas?: Map<string, (this: this, tui: this) => any> = new Map(); /** * Write index reference */ private writes?: number = 0; /** * Optional data store */ public data?: any; /** * The log-update instance */ private get update (): typeof update { return Log.update; }; /** * Constructor */ constructor (options?: TUIOptions & { id?: string }) { if (typeof options === 'object') { this.id = 'id' in options ? options.id : null; this.tree = 'tree' in options ? options.tree : true; this.type = 'type' in options ? options.type : 'info'; this.stack = 'stack' in options ? options.stack : []; if (this.tree) { if (this.type === 'error') { this.line = Tree.red; this.trim = Tree.redTrim; this.dash = Tree.redDash; } else if (this.type === 'warning') { this.line = Tree.yellow; this.trim = Tree.yellowTrim; this.dash = Tree.yellowDash; } else { this.line = Tree.line; this.trim = Tree.trim; this.dash = Tree.dash; } } else { this.line = ''; this.trim = ''; this.dash = ''; } } else { this.id = null; this.line = Tree.line; this.trim = Tree.trim; this.dash = Tree.dash; this.stack = []; } } /** * Log Output * * Writes to `process.stdout` or `process.stderr` or if custom stream was defined. * Calling this option will apply the following `toString` options: * * ```js * { * clear: false, // stack will NOT clear when calling toLog() * trim: false, // trim will NOT apply when calling toLog() * color: undefined * } * ``` * * **Example Usage** * * ```js * import * as _ from '@syncify/ansi' * * // Calling no parameter * _.Create().Line('foo').Line('bar').toLog() * * // Passing a callback function * _.Create().Line('foo').Line('bar').toLog((message) => {}) * * // Passing options with callback function * _.Create().Line('foo').Line('bar').toLog({ clear: true },(message) => {}) * ``` */ toLog (...input: toStringParam) { const options: toString = { clear: false, color: undefined, trim: false }; let callback: (message: string) => any = null; if (input.length > 0) { if (input.length === 1) { if (typeof input[0] === 'function') { callback = input[0]; } else { Object.assign(options, input[0]); } } else { Object.assign(options, input[0]); callback = input[1]; } } const output = this.toString(options, callback); if (this.type === 'error' || this.type === 'warning') { Log.stderr.write(output); } else { Log.stdout.write(output); } return this; } /** * Write Output * * Can be called multiple times, keeps track of stack index for each * call and prints from the last known index. This method is different * from `.toLog()` and `.toUpdate()` in the sense that stack is persisted * and only new stack entries print. * * ```js * { * clear: false, // stack will NOT clear when calling toWrite() * trim: false, // trim will NOT apply when calling toWrite() * color: undefined * } * ``` * * **Example Usage** * * ```js * import * as _ from '@syncify/ansi' * * const write = _.Create(); * * // Stack: ['│ foo'] * write * .Line('foo') * .toWrite() // Logs: │ foo\n * * // Stack: ['│ foo\n', '│ bar\n'] * write * .Line('bar') * .toWrite() // Logs: │ bar\n * * // Stack: ['│ foo\n', '│ bar\n', '│ baz\n'] * write * .Line('baz') * .toWrite() // Logs: │ bar\n * ``` */ toWrite (params?: Omit<toString, 'from'>) { const options = Object.assign({ clear: false, trim: false, color: undefined, from: this.writes }, params); const output = this.toString(options); if (this.type === 'error' || this.type === 'warning') { Log.stderr.write(output); } else { Log.stdout.write(output); } this.writes = this.index + 1; return this; } /** * Generate string with ending line * * Applies a `.join` glue to the `this.stack[]`. Unlike other output * methods, the `toLine` only accepts an {@link Ansis} color. * * Calling this option will apply the following `toString` options: * * ```js * { * clear: true, // stack will be reset * trim: true, // trim applies because newline line appends * color: undefined // Applied based on the parameter * } * ``` * * **Example** * * ```js * import * as _ from '@syncify/ansi' * * // Calling no parameter * _.Create().Line('foo').toLine() => '│ foo\n│ bar\n│' * * // Passing an ansis color * _.Create().Line('foo').toLine(_.gray) * ``` */ toLine (color?: Ansis) { if (this.stack.length === 0) return ''; this.stack[this.stack.length - 1] = this.stack[this.stack.length - 1].trimEnd(); this.stack.push('\n' + this.trim); const output: string = glue(this.stack); this.stack = []; this.track.clear(); if (color) return color(output); if (this.type === 'info') return white(output); if (this.type === 'error') return redBright(output); if (this.type === 'warning') return yellowBright(output); return output; } /** * Log Update * * Updates the previous `stdout` using {@link update} module. The * stack will be preserved and the last write will be removed, updated * with the current stack. * * Calling this option will apply the following `toString` options: * * ```js * { * clear: false, // stack is preserved by default in toUpdate * trim: false, // trim is not applied by default in toUpdate * } * ``` * * > The instance of log update is returned, so chaining cannot apply. * * **Example Usage** * * ```js * import * as _ from '@syncify/ansi' * * // Calling no parameter * _.Create().Line('foo').toUpdate() * * // Called log update methods * _.Create().Line('foo').toUpdate().done() * _.Create().Line('foo').toUpdate().clear() * ``` */ toUpdate (options?: { clear?: boolean, trim?: boolean, }) { if (options === null) return this; const output = this.toString({ clear: false, trim: false, ...options }); this.spin.stopOn = 'done'; this.update(output); return this; } /** * Generate string * * Applies a `.join` glue to the `text[]`, returning a string. * Applies trim any newlines in last entry, clears the `this.stack[]` array * and `track` Map. The resets can be prevented by passing `{ clear: false }` * as option. The defaults are as followed: * * ```js * { * clear: true, // stack will be reset * trim: true, // trim applies because newline line appends * color: undefined // Applied based on the parameter * } * ``` */ toString (...input: toStringParam) { if (this.stack.length === 0) return ''; const options: toString = { clear: true, trim: true, from: 0, color: undefined }; let callback: (message: string) => any = null; if (input.length > 0) { if (input.length === 1) { if (typeof input[0] === 'function') { callback = input[0]; } else { Object.assign(options, input[0]); } } else { Object.assign(options, input[0]); callback = input[1]; } } if (options.trim) this.stack[this.index] = this.stack[this.index].trimEnd(); const stack = options.from > 0 ? this.stack.slice(options.from) : this.stack; let output: string; if (options.color) { output = options.color(glue(stack)); } else if (this.type === 'info') { output = white(glue(stack)); } else if (this.type === 'error') { output = redBright(glue(stack)); } else if (this.type === 'warning') { output = yellowBright(glue(stack)); } else { output = glue(stack); } if (options.clear === true) { this.Reset(); } else if (Array.isArray(options.clear)) { for (const clear of options.clear) { if (this.track.has(clear)) { const track = this.track.get(clear); this.stack[track.index] = ''; } } } else if (typeof options.clear === 'string') { if (this.track.has(options.clear)) { const track = this.track.get(options.clear); this.stack[track.index] = ''; } } return callback === null ? output : callback(output); } /** * Return Structure * * Returns the current structure being built. * * @example * _.toStack() => ['│ foo', '│ bar', '│ baz'] */ toStack () { return this.stack; } /** * Function Lambda * * Tracks a function callback and fires on every call. * * @example * _.Lambda('foo', () => console.label('hello')) * * _.Lambda('foo') */ Lambda (id: string, callback?: (this: Tui, tui: Tui) => void) { if (typeof callback === 'function') { this.lamdas.set(id, callback); } else if (this.lamdas.has(id)) { if (callback === null) { this.lamdas.delete(id); } else { this.lamdas.get(id).call(this, this); } } return this; } /** * String * * Similar to `toString()` but returns instance */ String (options: toString, callback: (message: string) => void) { callback(this.toString(options)); return this; } /** * True Conditional * * If parameter 1 is `truthy`, parameter to will trigger. * * @example * _.True(foo === false, function(tui) { * * // context is parameter * tui.Line('Hello World') * * // this binding applies * this.Line('Hello World') * }) */ True (condition: any, callback: (this: Tui, tui?: Tui) => void) { if (condition) callback.call(this, this); return this; } /** * False Conditional * * If parameter 1 is `falsy`, parameter to will trigger. * * @example * _.False(foo === false, function(tui) { * * // context is parameter * tui.Line('Hello World') * * // this binding applies * this.Line('Hello World') * }) */ False (condition: any, callback: (this: Tui, tui?: Tui) => void) { if (!condition) callback.call(this, this); return this; } /** * Update the newline lines * * Allows for the tree lines to be changed, but no modification applies to text. */ Tree (tree?: 'error' | 'warning' | 'info' | 'nil') { if (tree === 'error') { this.line = Tree.red; this.trim = Tree.redTrim; this.dash = Tree.redDash; } else if (tree === 'warning') { this.line = Tree.yellow; this.trim = Tree.yellowTrim; this.dash = Tree.yellowDash; } else if (tree === 'nil') { this.line = ''; this.trim = ''; this.dash = ''; } else { this.line = Tree.line; this.trim = Tree.trim; this.dash = Tree.dash; } return this; } /** * Each Iterator * * Acccepts a array and callback function. * * @example * _.Each(['foo', 'bar'], item => _.Line(item)) */ Each <T> (array: T[], callback: (this: Tui, item?: T, index?: number) => void) { for (let i = 0, s = array.length; i < s; i++) callback.call(this, array[i], i); return this; } /** * Reset stack and track * * Empties the `stack[]` and clears the `track` map. */ Reset () { this.stack = []; this.track.clear(); this.writes = 0; if (this.id !== null && Tui.store.has(this.id)) Tui.store.delete(this.id); } /** * is Empty * * Whether or not the message stack is empty */ get isEmpty () { return this.stack.length > 0; } /** * is Endline * * Whether or not the last item in the stack ends with a newline character */ get isEndline () { if (this.stack.length > 0) { const last = this.Get(); return last[last.length - 1] === '\n'; } return false; } /** * Get Line * * Returns a line at the specific index. Defaults to last known line */ Get (at: string | number = this.stack.length - 1) { if (typeof at === 'string' && this.track.has(at)) at = this.track.get(at).index; return this.stack[at]; } /** * Track Stack entry * * When called, an index in the stack is tracked. The message in the stack * can then be referenced and updated at a later time using `Update`. If * the stack is empty, no track applies. * * The function **must** be called following a write method and the last known * entry index in the stack is what is saved. If a tacked reference exists * with the `id` provided, it will be overwritten. * * All tracked references are cleared on `toString` or `toLine` */ Template (...input: [ message: string | string[], options: TemplateOptions<Templates> ] | [ options: TemplateOptions<Templates> ]) { const message = input.length === 2 ? input[0] : null; const options: TemplatePrivate = Object.assign<TemplatePrivate, any>({ color: null, prefix: false, insert: false, hidden: false, id: null, label: null, message: null, dash: false, index: this.stack.length }, message ? input[1] : input[0]); if (typeof options.prefix === 'string') { options.label = options.prefix; options.prefix = true; } if (message !== null) { const write = Array.isArray(message) ? message : [ message ]; if (options.hidden) { options.message = write; this.stack.push(''); } else { if (options.prefix) { this.stack.push( Prefix( typeof options.label === 'string' ? options.label : options.id, options.color ? options.color(glue(write)) : glue(write) ) + NWL ); } else { this.stack.push(Multiline(write, { color: options.color, line: options.dash ? this.dash : this.line }) + NWL); } } } else { this.stack.push(''); } if (options.id !== null) { if (options.index !== this.stack.length - 1) options.index = this.stack.length - 1; this.track.set(options.id, options); } return this; } /** * Tree Update * * Updates a stack entry at either a `Track()` identifier index or index (depending on `id`) * parameter `type` provided. The stack will be augmented and updated, at the index provided. * Passing an `string[]` input will result in spliced insertion. * * @example * // Assuming Template('ref') was called during message creation * * // If ref was index 1 in the stack * _.Update('ref', ['hello', 'world']) * * // Before * ['│ foo\n', '│ bar\n', '│ baz\n'] * // After * ['│ foo\n', '│ hello\n', '│ world\n', '│ baz\n'] */ Update (id: Templates, input: string | string[] = null, newColor: Ansis = null) { let index: number = NaN; let track: TemplatePrivate; if (typeof id === 'string' && this.track.has(id)) { track = this.track.get(id); index = track.index; } if (isNaN(index) || typeof this.stack[index] !== 'string') return this; const lines = track.hidden ? input === null ? [ ...track.message ] : [ '' ] : typeof input === 'string' ? [ input ] : Array.isArray(input) ? input : [ `${input}` ]; const newline: boolean = lines.length > 1; const replace: string[] = []; const color = newColor || track.color; const { prefix, insert, label, dash } = track; const line = dash ? this.dash : this.line; let tree: number = 0; while (lines.length !== 0) { const line = lines.shift(); newline && tree > 0 && insert === false ? replace.push(line + (color ? color(line) : line)) : replace.push((color ? color(line) : line)); tree++; } const output = prefix ? Prefix(typeof label === 'string' ? label : id, glue(replace)) : newline ? glue.nl(replace) : glue(replace); if (insert) { this.stack.splice(index, 1, output); } else { this.stack.splice(index, 1, line + output + '\n'); } return this; } /** * TUI Spinner * * Prints a spinning loading and persists within stack Calling `this.toUpdate()` each interval. * Can be used with `this.Stop()`. Renders a `Header` entry. * * @example * // Spinner will begin immediately * _.Line('foo').Spinner('bar') * * // Stack input - notice how a header is applied * ['│ foo\n', '│\n│ ◓ bar\n│\n'] * * // When we want to stop and clear spinner * _.Stop() */ Spinner (message: string, options?: { style?: 'spinning' | 'brielle', color?: Ansis }) { options = Object.assign({ style: 'spinning', color: neonTeal }, { color: this.spin.color, style: this.spin.style }, options); if (this.spin.active === false) { if (this.spin.stopOn === 'done') { this.update.clear(); } let frame: number = 0; this.spin.style = options.style as any; const spin = Spinner.loaders[this.spin.style]; const frames = spin.frames; const size = frames.length; this.spin.index = this.stack.push('') - 1; this.spin.color = options.color; this.spin.label = message; this.spin.active = true; this.update(this.line + gray.dim('...')); this.spin.interval = setInterval(() => { if (this.spin.active) { this.update(glue( this.line, this.spin.color(frames[++frame % size] + WSP + this.spin.label), NWL )); } }, spin.interval); } else { this.spin.label = message; this.spin.color = options.color; } return this; } /** * TUI Stop Spinner * * When spinner is active, calling this will stop and remove the spinner * from the stack. Optionally update the spinner value to preserve. * * > **NOTE** Passing an update will render as line, not Header */ Stop (update?: string, color?: Ansis) { if (this.spin.interval !== null) { clearInterval(this.spin.interval); } if (this.spin.active === false) { this.update.done(); return this; } this.update.clear(); this.spin.active = false; this.spin.interval = null; this .True(this.writes > 0, () => this.Remove(this.spin.index, Infinity)) .True(update, () => this.Line(update, color)); this.spin.index = NaN; return this; } /** * Checks if previous stack entry is tree line and pops it * if determined to be true. */ Trim () { const previous = this.stack[this.stack.length - 1] + '\n'; if (!previous) return this; if ( previous === Tree.line || previous === Tree.trim || previous === Tree.red || previous === Tree.redTrim || previous === Tree.yellow || previous === Tree.yellowTrim) this.Pop(); return this; } /** * Remove Line * * Removes a line at specific index. Can apply a slice or splice. * Passing a `deleteCount` value of `Infinity` will slice stack at the index. * * @example * // Assuming the stack contains the following: * [ * '│ foo', * '│ bar', * '│ baz' * ] * * // Calling .remove(0) will remove first index * [ * '│ bar', * '│ baz' * ] */ Remove (at: number | string, deleteCount: number | string = 1) { let index: number; if (typeof at === 'string') { if (!this.track.has(at)) return this; index = this.track.get(at).index; this.track.delete(at); } else { index = at; } if (deleteCount === Infinity) { this.stack.splice(index); // Remove all IDs that point to removed indices for (const [ otherId, data ] of this.track.entries()) { if (data.index >= index) { this.track.delete(otherId); } } this.stack = this.stack.slice(0, index); } else { let ender: number; if (typeof deleteCount === 'string') { if (this.track.has(deleteCount)) { ender = this.track.get(deleteCount).index; this.track.delete(deleteCount); } else { ender = 1; } } else { ender = deleteCount; } this.stack.splice(index, ender); for (const [ id, track ] of this.track.entries()) { if (track.index > index) { this.track.get(id).index = track.index - ender; } } } return this; } /** * Mark Stack * * Inserts a fake placeholder that is to be removed or replaced at a later time. * The `track` Map will assign `insert` to `true` to prevent newline line insertion. * * @example * _.Mark('xxx') * * // Before * [ '│ foo', '│ bar' ] * * // After * [ '│ foo', '│ bar', ''] * * // Later on * _.Remove('xxx') * * // Use Infinity to slice at mark * _.Remove('xxx', Infinity) */ Mark (id: string) { this.track.set(id, { id, index: this.stack.length, label: null, prefix: false, color: undefined, insert: false, dash: false, hidden: false, message: null }); this.stack.push(''); return this; } /** * Replace and persist * * Replaces an entry at the provided index. Line is prefixed and not required in `input` * * @example * _.Replace(1, 'qux') * * // Before * [ '│ foo', '│ bar', '│ baz' ] * * // After * [ '│ foo', '│ qux', '│ baz' ] */ Replace (at: number | string, input: string, color?: Ansis) { let index: number; if (typeof at === 'string') { if (!this.track.has(at)) return this; index = this.track.get(at).index; } else { index = at; } if (this.stack[index]) { this.stack[index] = this.line + (color ? color(input) : input) + '\n'; } return this; } /** * Tree Horizontal Line * * Prints a horizontal line separator which will default to * spanning the `wrap` of the terminal pane. * * ```bash * # When Tree is enabled * │\n * ├─────────────────────\n * │\n * * # When Tree is disabled * ──────────────────────\n * ``` */ Ruler (width: number = undefined, { noLines = false } = {}) { if (width === undefined) width = tsize().wrap; if (this.tree) { if (noLines) { this.stack.push(lightGray(`├${'─'.repeat(width)}`) + '\n'); } else { this.stack.push(Tree.trim + '\n' + lightGray(`├${'─'.repeat(width)}`) + '\n' + Tree.trim + '\n'); } } else { this.stack.push(lightGray('─'.repeat(width)) + '\n'); } return this; } /** * Returns the current text index in the stack */ get index () { return this.stack.length - 1; } get newlines () { return this.stack.join('').split(NWL).length; } /** * Tree Newline * * Works the same as `Newline()` but is exposed as getter * * ```bash * │\n * ``` */ get NL () { this.stack.push(this.trim + '\n'); return this; } /** * Newline only * * Pushed a newline into the stack * * ```bash * \n * ``` */ get BR () { this.stack.push('\n'); return this; } /** * Newline only * * Pushes a single `\n` newline into the stack or * multiple newlines if `repeat` parameter is provided. * * ```bash * \n * ``` */ Break (repeat?: number) { if (typeof repeat === 'number') { this.stack.push('\n'.repeat(repeat)); } else { this.stack.push('\n'); } return this; } /** * Tree Pop * * Removes the last entry in the message stack. Accepts * a number parameter to increase the amount of removals * to occur. * * ```bash * │\n * ``` * * @example * // Assuming the stack contains the following: * [ * '│ foo', * '│ bar', * '│ baz' * ] * * // Calling .pop() will remove the last entry: * [ * '│ foo', * '│ bar' * ] */ Pop (amount: number = 1) { while (amount-- > 0) this.stack.pop(); return this; } /** * Tree Newline * * Returns a newline, accepts `addLines` parameter that accepts a `number` * and when provided will generate multiple newlines. In addition (or optionally) * a `color` can be provided, which expects a valid color string name. * * ```bash * │\n * ``` * * --- * * **Passing Color** * * Passing `Newlines('red')` will a line in red. * * ```bash * │\n * ``` * * --- * * **Passing Lines and Color** * * Passing `Newlines(2, 'red')` will generate the following string in red. * * ```bash * │\n * │\n * ``` */ Newline ( addLines?: number | LiteralUnion<'line' | 'red' | 'yellow', string>, color?: LiteralUnion<'red' | 'yellow', string> ) { if (typeof addLines === 'number') { let input: string = this.trim + '\n'; if (color) { if (this.tree) { if (color === 'yellow') { input = Tree.yellowTrim + '\n'; } else if (color === 'red') { input = Tree.redTrim + '\n'; } } } for (let i = 0; i < addLines; i++) this.stack.push(input); } else { if (addLines === '') { this.stack.push('\n'); } else if (addLines === 'line') { this.stack.push(Tree.trim + '\n'); } else if (addLines === 'yellow') { this.stack.push((this.tree ? Tree.yellowTrim : '') + '\n'); } else if (addLines === 'red') { this.stack.push((this.tree ? Tree.redTrim : '') + '\n'); } else if (typeof addLines === 'string') { this.stack.push(addLines + '\n'); } else { this.stack.push(this.trim + '\n'); } } return this; } /** * Tree Inline * * Appends to the previous entry. If no entries exist in the message, a new one is * created with tree line prefix. * * > Use `Push()` method to insert entry without line prefix. * * @example * _.Inline('baz qux') * * // Before * [ '│ hello', '│ foo bar\n' ] * * // After * [ '│ hello', '│ foo bar baz qux\n' ] * * // If the stack is empty, default behaviour applied * * // Before * [] * * // After * [ '│ baz qux' ] */ Inline (input: string, ...options: [ number?, Ansis? ] | [ Ansis? ] | [ number? ]) { let index: number = this.stack.length > 0 ? this.stack.length - 1 : NaN; let color: Ansis = null; if (options.length > 0) { if (options.length === 2) { index = options[0]; color = options[1]; } else if (options.length === 1) { if (typeof options[0] === 'number') { index = options[0]; } else { color = options[0]; } } } if (index > -1) { this.stack[index] = this.stack[index].trimEnd() + ' ' + (color ? color(input) : input) + '\n'; } else { this.stack.push(this.line + (color ? color(input) : input) + '\n'); } return this; } /** * Tree Insert * * Pushes input onto the stack, but does not prefix line or append newline. * Inserts the `input` as is, and accepts an optional `color` function. * * @example * _.Insert('bar baz qux') * * // Before * [ '│ hello', '│ foo' ] * * // After * [ '│ hello', '│ foo', 'bar baz qux' ] */ Insert (input: string, color?: Ansis) { this.stack.push((color ? color(input) : input)); return this; } /** * Tree Line * * Pushes a string onto the message stack. Prefixes with a `│` and * suffixes with newline `\n`. This is _typically_ the most common method. * * ```bash * │ input\n * ``` * * @example * _.Line('world') * * // Before * [ '│ hello\n' ] * * // After * [ '│ hello\n', '│ world\n' ] */ Line (input: string, color?: Ansis) { if (this.type === 'error') { return this.Error(input, color); } if (this.type === 'warning') { return this.Warn(input, color); } this.stack.push(this.line + (color ? color(input) : input) + '\n'); return this; } /** * Tree Prefix * * Applies the {@link Prefix} render on a line. * * Equally distributes whitespace following the `prefix` parameter. * Optionally accepts a `suffix[]` string spread. Depending on the number * of suffix appends passed, different output is produced. When passing * `3 or 4` suffixes the last known suffix will apply `~` appenditure. * * See below examples: * * --- * * **Passing 0 `suffix` parameters** * * ```bash * │ prefix * ``` * * --- * * **Passing 1 `suffix` parameter** * * ```bash * │ prefix » action * ``` * --- * * **Passing 2 `suffix` parameters** * * ```bash * │ prefix » action → suffix * ``` * * --- * * **Passing 3 `suffix` parameters** * * ```bash * │ prefix » action → suffix ~ append * ``` * * --- * * **Passing 4 `suffix` parameters** * * ```bash * │ prefix » handle ⥂ joiner → action ~ append * ``` */ Prefix (label: Prefixes, ...suffix: [ string, Ansis? ] | [ string, string, Ansis? ] | [ string, string, string, Ansis? ] | [ string, string, string, string, Ansis? ]) { const color = typeof suffix[suffix.length - 1] === 'function' ? suffix.pop() as Ansis : null; const text = color ? suffix.map((item: string) => color(item)) : suffix as string[]; const input = Prefix(label, ...text); this.stack.push(this.line + input + '\n'); return this; } /** * Prepend Line * * Pushes a string onto the message stack with a newline line prepended * * ```bash * │\n * │ input\n * ``` * * @example * _.Prepend('world') * * // Before * [ '│ hello\n' ] * * // After * [ '│ hello\n', '│\n│ world\n' ] */ Prepend (input: string, color?: Ansis) { if (this.type === 'error') { return this.NL.Error(input, color); } else if (this.type === 'warning') { return this.NL.Warn(input, color); } return this.NL.Line(input, color); } /** * Append Line * * Pushes a string onto the message stack. Appended with a newline line `│` and * suffixes with newline `\n`. * * ```bash * │ input\n * │\n * ``` * * @example * _.Append('world') * * // Before * [ '│ hello\n' ] * * // After * [ '│ hello\n', '│ world\n│\n' ] */ Append (input: string, color?: Ansis) { if (this.type === 'error') { this.Error(input, color); } else if (this.type === 'warning') { this.Warn(input, color); } else { this.Line(input, color); } return this.Newline(); } /** * Tree Error Line (red) * * Same as `Line()` but tree line suffix is `red` * * ```bash * │ input\n * ``` */ Error (input: string, color?: Ansis) { this.stack.push((this.tree ? Tree.red : '') + (color ? color(input) : redBright(input)) + '\n'); return this; } /** * Tree Warn Line (yellow) * * Same as `Line()` but tree line suffix is `yellow` * * ```bash * │ input\n * ``` */ Warn (input: string, color?: Ansis) { this.stack.push((this.tree ? Tree.yellow : '') + (color ? color(input) : yellowBright(input)), '\n'); return this; } /** * Tree Line Break * * Appends and Prepends newlines, effectively wrapping the `input` in * paragraphical format. * * ```js * // When tree is enabled * │\n * │ input\n * │\n * * // When tree is disabled * \n * input\n * \n * ``` */ Header (message: string, color?: Ansis) { this.stack.push( this.trim + '\n' + this.line + (color ? color(message) : message) + '\n' + this.trim + '\n' ); return this; } /** * Tree Top * * ``` * '\n┌─ Label ~ 01:59:20\n' * ``` */ Top (label: string, timestamp = true) { this.stack.push(Top(label, timestamp) + '\n'); return this; } /** * Tree End * * Returns a tree ender with optional timestamp suffix appended. * Timestamp suffix defaults to `true` and will be applied. * * ```js * '└─ input ~ 01:59:20\n' // Passing true to timestamp (default) * // OR * '└─ input\n' // Passing false to timestamp * ``` */ End (input: string, timestamp = true) { this.stack.push(End(input, timestamp)); return this; } /** * Tree Context * * Accepts a contextual model. The context will be parsed and * pushed onto the stack. * * ``` * │ * │ code: 422 * │ file: ~source/dir/filename.liquid * │ status: Unprocessed Entity * │ * │ Type s and press enter to view stack trace * ``` */ Context (data: IssueContext) { if (!('tree' in data)) data.tree = this.line !== ''; this.stack.push(Context(data) + '\n'); return this; } /** * Tree Dash * * Applies prefixed tree dash to input * * ```js * // When tree is enabled * ├─ input\n * * // When tree is disabled * — input\n * ``` */ Dash (input: string, color?: Ansis) { this.stack.push((this.tree ? this.dash : `${DSH} `) + (color ? color(input) : input) + '\n'); return this; } /** * Tree Multiline * * Prefixes a multiline string with tree line. This method does * not apply wrap, but instead applies a `.split('\n')` on string * input (if single string is passed). The method accepts `...string` * spread or `string[]` parameter value. * * ``` * │ lorem ipsum lorem ipsum\n * │ lorem ipsum lorem ipsum\n * │ lorem ipsum lorem ipsum\n * ``` * * @example * // Passing a string with newlines * _.Multline('hello\nworld') => [ '│ hello\n', '│ world\n' ] * * // Passing an array of strings * _.Multline(['hello', 'world']) => [ '│ hello\n', '│ world\n' ] * * // Passing a spread * _.Multline('hello', 'world') => [ '│ hello\n', '│ world\n' ] */ Multiline (...input: [ string[] ] | string[]) { const lines = typeof input[0] === 'string' ? input.length === 1 ? input[0].split('\n') : input : input[0]; while (lines.length !== 0) { this.stack.push(this.line + lines.shift() + '\n'); } return this; } /** * Tree Unshift * * Inserts a string onto the message stack at index `0`. Prefixes with a `│` and * suffixes with newline `\n`. * * > If `type` is `error` or `warning` and you want to prevent the red or yellow * color highlighting, then pass a value of `null` to color parameter. * * ```bash * │ input\n * ``` * * @example * _.Unshift('world', 0) * * // Before * [ '│ hello\n' ] * * // After * ['│ world\n', '│ hello\n' ] */ Unshift (input: string, color?: Ansis) { if (!color) { if (color !== null) { if (this.type === 'error') color = redBright; if (this.type === 'warning') color = yellowBright; } } this.stack.push(this.line + (color ? color(input) : input) + '\n'); return this; } /** * Tree Wrap * * Accepts `string[]` or `...string[]` spread. The last entry accepts an * optional Ansis color. The **input** will be passed to {@link Wrap} and the * returning output will end with newline. * * ``` * │ lorem ipsum lorem ipsum\n * │ lorem ipsum lorem ipsum\n * │ lorem ipsum lorem ipsum\n * ``` */ Wrap (...input: (string[] | string | Ansis | ((message: string) => string))[]) { let color: Ansis = whiteBright; if (this.type === 'error') { color = redBright; } else if (this.type === 'warning') { color = yellowBright; } if (typeof input[0] === 'string') { if (typeof input[input.length - 1] === 'function') { color = <Ansis>input.pop(); } this.stack.push(Wrap(<string[]>input, { color, line: this.line }) + '\n'); } else if (Array.isArray(input[0])) { if (typeof input[1] === 'function') color = <Ansis>input.pop(); this.stack.push(Wrap(input[0], { color, line: this.line }) + '\n'); } else if (typeof input[0] === 'function') { color = <Ansis>input.shift(); this.stack.push(Wrap(<string[]>input, { color, line: this.line }) + '\n'); } else if (Array.isArray(input[1])) { color = <Ansis>input[0]; this.stack.push(Wrap(<string[]>input[1], { color, line: this.line }) + '\n'); } return this; } } type CreateParams = [ id: string, options?: TUIOptions ] | [ options?: TUIOptions ] /** * Create a TUI Instance * * ```bash * ┌─ * │ * ├─ * │ * └─ * ``` */ export function Create <Templates extends string> (...params: CreateParams) { let id: string; let options: TUIOptions & { id?: string }; if (params.length === 2) { id = params[0]; options = params[1]; } else if (params.length === 1) { if (typeof params[0] === 'string') { id = params[0]; } else { options = params[0]; } } if (id) { if (options) { options.id = id; } else { options = { id }; } const instance = new Tui<Templates>(options); return Tui.store.set(id, instance).get(id); } return new Tui<Templates>(options); } /** * Create Self-Maintained TUI Instance * * ```bash * ┌─ * │ * ├─ * │ * └─ * ``` */ export function TUI (id: string) { if (Tui.store.has(id)) return Tui.store.get(id); return Create(id); }