@travetto/terminal 3.0.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ <!-- This file was generated by @travetto/doc and should not be modified directly -->
2
+ <!-- Please modify https://github.com/travetto/travetto/tree/main/module/terminal/DOC.ts and execute "npx trv doc" to rebuild -->
3
+ # Terminal
4
+ ## General terminal support
5
+
6
+ **Install: @travetto/terminal**
7
+ ```bash
8
+ npm install @travetto/terminal
9
+ ```
package/__index__.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from './src/codes';
2
+ export * from './src/color-define';
3
+ export * from './src/color-output';
4
+ export * from './src/iterable';
5
+ export * from './src/named-colors';
6
+ export * from './src/operation';
7
+ export * from './src/query';
8
+ export * from './src/terminal';
9
+ export * from './src/types';
10
+ export * from './src/writer';
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@travetto/terminal",
3
+ "version": "3.0.0-rc.10",
4
+ "description": "General terminal support",
5
+ "keywords": [
6
+ "terminal",
7
+ "ansi256",
8
+ "travetto",
9
+ "typescript"
10
+ ],
11
+ "homepage": "https://travetto.io",
12
+ "license": "MIT",
13
+ "author": {
14
+ "email": "travetto.framework@gmail.com",
15
+ "name": "Travetto Framework"
16
+ },
17
+ "files": [
18
+ "__index__.ts",
19
+ "src"
20
+ ],
21
+ "main": "__index__.ts",
22
+ "repository": {
23
+ "url": "https://github.com/travetto/travetto.git",
24
+ "directory": "module/terminal"
25
+ },
26
+ "travetto": {
27
+ "displayName": "Terminal"
28
+ },
29
+ "private": false,
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }
package/src/codes.ts ADDED
@@ -0,0 +1,41 @@
1
+ const ESC = '\x1b[';
2
+ const OSC = '\x1b]';
3
+ const ST = '\x1b\\';
4
+
5
+ // eslint-disable-next-line no-control-regex
6
+ export const ANSI_CODE_REGEX = /(\x1b|\x1B)[\[\]][?]?[0-9;]+[A-Za-z]/g;
7
+
8
+ export const OSC_QUERY_FIELDS = {
9
+ backgroundColor: 11,
10
+ foregroundColor: 10
11
+ };
12
+
13
+ export type OSCQueryField = keyof (typeof OSC_QUERY_FIELDS);
14
+
15
+ export const DEVICE_STATUS_FIELDS = {
16
+ cursorPosition: 6,
17
+ };
18
+
19
+ export type DeviceStatusField = keyof (typeof DEVICE_STATUS_FIELDS);
20
+
21
+ export const ANSICodes = {
22
+ DEBUG: (text: string): string => text.replaceAll(ESC, '<ESC>').replaceAll(OSC, '<OSC>').replaceAll('\n', '<NL>').replaceAll(ST, '<ST>'),
23
+ CURSOR_DY: (row: number): string => `${ESC}${Math.abs(row)}${row < 0 ? 'A' : 'B'}`,
24
+ CURSOR_DX: (col: number): string => `${ESC}${Math.abs(col)}${col < 0 ? 'D' : 'C'}`,
25
+ COLUMN_SET: (col: number): string => `${ESC}${col}G`,
26
+ POSITION_SET: (row: number, col: number): string => `${ESC}${row};${col}H`,
27
+ ERASE_SCREEN: (dir: 0 | 1 | 2 | 3 = 0): string => `${ESC}${dir}J`,
28
+ ERASE_LINE: (dir: 0 | 1 | 2 = 0): string => `${ESC}${dir}K`,
29
+ STYLE: (codes: (string | number)[]): string => `${ESC}${codes.join(';')}m`,
30
+ SHOW_CURSOR: (): string => `${ESC}?25h`,
31
+ HIDE_CURSOR: (): string => `${ESC}?25l`,
32
+ SCROLL_RANGE_SET: (start: number, end: number): string => `${ESC}${start};${end}r`,
33
+ SCROLL_RANGE_CLEAR: (): string => `${ESC}r`,
34
+ SCROLL_WINDOW: (rows: number): string => `${ESC}${Math.abs(rows)}${rows < 0 ? 'S' : 'T'}`,
35
+ POSITION_RESTORE: (): string => `${ESC}u`,
36
+ POSITION_SAVE: (): string => `${ESC}s`,
37
+ DEVICE_STATUS_REPORT: (code: DeviceStatusField): string => `${ESC}${DEVICE_STATUS_FIELDS[code]}n`,
38
+ OSC_QUERY: (code: OSCQueryField): string => `${OSC}${OSC_QUERY_FIELDS[code]};?${ST}`,
39
+ };
40
+
41
+ export const stripAnsiCodes = (text: string): string => text.replace(ANSI_CODE_REGEX, '');
@@ -0,0 +1,195 @@
1
+ import { NAMED_COLORS } from './named-colors';
2
+ import { RGB, TermColorScheme } from './types';
3
+
4
+ type I = number;
5
+ type HSL = [h: I, s: I, l: I];
6
+ export type DefinedColor = { rgb: RGB, hsl: HSL, idx16: I, idx16bg: I, idx256: I, scheme: TermColorScheme };
7
+ export type RGBInput = I | keyof (typeof NAMED_COLORS) | `#${string}`;
8
+
9
+ const _rgb = (r: I, g: I = r, b: I = g): RGB => [r, g, b];
10
+
11
+ const STD_PRE = ['std', 'bright'] as const;
12
+ const STD_COLORS = ['Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White'] as const;
13
+
14
+ // eslint-disable-next-line no-bitwise
15
+ const toRgbArray = (val: number): RGB => [(val >> 16) & 255, (val >> 8) & 255, val & 255];
16
+
17
+ const ANSI256_TO_16_IDX = [30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97];
18
+ const ANSI16_TO_BG = new Map(ANSI256_TO_16_IDX.map(x => [x, x + 10]));
19
+
20
+ // Inspired/sourced from: https://github.com/mina86/ansi_colours/blob/master/src/ansi256.rs
21
+ const ANSI256_GRAY_MAPPING = [
22
+ [16, 5],
23
+ [232, 9], [233, 10], [234, 10], [235, 10], [236, 10], [237, 10], [238, 10], [239, 10], [240, 8],
24
+ [59, 5],
25
+ [241, 7], [242, 10], [243, 9], [244, 9],
26
+ [102, 5],
27
+ [245, 6], [246, 10], [247, 10], [248, 9],
28
+ [145, 5],
29
+ [249, 6], [250, 10], [251, 10], [252, 9],
30
+ [188, 5],
31
+ [253, 6], [254, 10], [255, 14],
32
+ [231, 9]
33
+ ].flatMap(([v, r]) => new Array<number>(v).fill(r));
34
+
35
+ const STEPS_256 = [0, 95, 135, 175, 215, 255];
36
+
37
+ const ANSI256_TO_RGB: RGB[] = [
38
+ ...STD_PRE.flatMap(p => STD_COLORS.map(c => NAMED_COLORS[`${p}${c}`]).map(toRgbArray)),
39
+ ...STEPS_256.flatMap(r => STEPS_256.flatMap(g => STEPS_256.map(b => _rgb(r, g, b)))),
40
+ ...new Array(24).fill(0).map((_, i) => _rgb(i * 10 + 8)) // Grays
41
+ ];
42
+
43
+ // Pulled from: https://github.com/mina86/ansi_colours/blob/master/src/ansi256.rs
44
+ const RED_256_LEVELS = [38, 115, 155, 196, 235];
45
+ const GREEN_256_LEVELS = [38, 115, 155, 196, 235];
46
+ const BLUE_256_LEVELS = [35, 115, 155, 195, 235];
47
+
48
+ export class ColorDefineUtil {
49
+ static CACHE = new Map<number | string, DefinedColor>();
50
+
51
+ static #snapToLevel(levels: number[], val: number): number {
52
+ for (let i = 0; i < levels.length; i += 1) {
53
+ if (val < levels[i]) { return i; }
54
+ }
55
+ return levels.length;
56
+ }
57
+
58
+ // Returns luminance of given sRGB color.
59
+ // Pulled from: https://github.com/mina86/ansi_colours/blob/master/src/ansi256.rs
60
+ // eslint-disable-next-line no-bitwise
61
+ static #luminance = ([r, g, b]: RGB): number => (((3567664 * r + 11998547 * g + 1211005 * b) + (1 << 23)) >> 24) & 255;
62
+
63
+ // Pulled from: https://github.com/mina86/ansi_colours/blob/master/src/ansi256.rs
64
+ static #distance = ([xr, xg, xb]: RGB, [yr, yg, yb]: RGB, sr = xr + yr): number =>
65
+ (1024 + sr) * ((xr - yr) ** 2) + 2048 * ((xg - yg) ** 2) + (1534 - sr) * ((xb - yb) ** 2);
66
+
67
+ static #snapToAnsi256Bands([r, g, b]: RGB): [idx: number, snapped: RGB] {
68
+ const ri = this.#snapToLevel(RED_256_LEVELS, r);
69
+ const gi = this.#snapToLevel(GREEN_256_LEVELS, g);
70
+ const bi = this.#snapToLevel(BLUE_256_LEVELS, b);
71
+ return [(ri * 36 + 16 + gi * 6 + bi), [STEPS_256[ri], STEPS_256[gi], STEPS_256[bi]]];
72
+ }
73
+
74
+ /**
75
+ * Converts [R,G,B] to an ANSI 256 index
76
+ */
77
+ // Inspired/sourced from: https://github.com/mina86/ansi_colours/blob/master/src/ansi256.rs
78
+ static ansi256FromRgb(rgb: RGB): number {
79
+ const grayIdx = ANSI256_GRAY_MAPPING[this.#luminance(rgb)];
80
+ const grayDist = this.#distance(rgb, ANSI256_TO_RGB[grayIdx]);
81
+ const [snappedIdx, snapped] = this.#snapToAnsi256Bands(rgb);
82
+ return this.#distance(rgb, snapped) < grayDist ? snappedIdx : grayIdx;
83
+ }
84
+
85
+ /**
86
+ * Converts [R,G,B] to an ANSI 16 index
87
+ */
88
+ static ansi16FromRgb(rgb: RGB): number {
89
+ let min = Number.MAX_SAFE_INTEGER;
90
+ let minIdx = -1;
91
+ for (let i = 0; i < 16; i += 1) {
92
+ const dist = this.#distance(rgb, ANSI256_TO_RGB[i]);
93
+ if (dist < min) {
94
+ min = dist;
95
+ minIdx = i;
96
+ }
97
+ }
98
+ return ANSI256_TO_16_IDX[minIdx];
99
+ }
100
+
101
+ /**
102
+ * ANSI 256 into RGB
103
+ */
104
+ static rgbFromAnsi256(val: number): RGB {
105
+ return ANSI256_TO_RGB[val];
106
+ }
107
+
108
+ /**
109
+ * Converts [R,G,B] to [H,S,L]
110
+ */
111
+ static hsl([r, g, b]: RGB): HSL {
112
+ const [rf, gf, bf] = [r / 255, g / 255, b / 255];
113
+ const max = Math.max(rf, gf, bf), min = Math.min(rf, gf, bf);
114
+ let h = 0;
115
+ let s = 0;
116
+ const l = (max + min) / 2;
117
+
118
+ if (max === min) {
119
+ h = s = 0; // achromatic
120
+ } else {
121
+ const d = max - min;
122
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
123
+ switch (max) {
124
+ case rf: h = (gf - bf) / d + (gf < bf ? 6 : 0); break;
125
+ case gf: h = (bf - rf) / d + 2; break;
126
+ case bf: h = (rf - gf) / d + 4; break;
127
+ }
128
+ h /= 6;
129
+ }
130
+ return [h, s, l];
131
+ }
132
+
133
+
134
+ /**
135
+ * Converts input value into [R,G,B] output
136
+ */
137
+ static toRgb(val: RGBInput): RGB {
138
+ if (typeof val === 'string') {
139
+ if (val.startsWith('#')) {
140
+ const res = val.match(/^#(?<rh>[a-f0-9]{2})(?<gh>[a-f0-9]{2})(?<bh>[a-f0-9]{2})$/i);
141
+ if (res) {
142
+ const { rh, gh, bh } = res.groups!;
143
+ return [parseInt(rh, 16), parseInt(gh, 16), parseInt(bh, 16)];
144
+ }
145
+ } else if (val in NAMED_COLORS) {
146
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
147
+ return this.toRgb(NAMED_COLORS[val as keyof typeof NAMED_COLORS]);
148
+ }
149
+ throw new Error(`Unknown color format: ${val}`);
150
+ } else if (typeof val === 'number') {
151
+ return toRgbArray(val);
152
+ } else {
153
+ return val;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Determine if a color is light or dark
159
+ */
160
+ static getScheme(color: RGB): TermColorScheme {
161
+ const [r, g, b] = color;
162
+ return (r + g + b) / 3 < 128 ? 'dark' : 'light';
163
+ }
164
+
165
+ /**
166
+ * Define a color and all its parts
167
+ */
168
+ static defineColor(val: RGBInput): DefinedColor {
169
+ if (!this.CACHE.has(val)) {
170
+ const rgb = this.toRgb(val);
171
+ const idx16 = this.ansi16FromRgb(rgb);
172
+ const idx256 = this.ansi256FromRgb(rgb);
173
+ const hsl = this.hsl(rgb);
174
+ const idx16bg = ANSI16_TO_BG.get(idx16)!;
175
+ const scheme = this.getScheme(rgb);
176
+ this.CACHE.set(val, { rgb, idx16, idx16bg, idx256, hsl, scheme });
177
+ }
178
+ return this.CACHE.get(val)!;
179
+ }
180
+
181
+ /**
182
+ * Build ANSI compatible color codes by level
183
+ */
184
+ static getColorCodes(inp: RGBInput, bg: boolean): [number[], number[]][] {
185
+ const spec = this.defineColor(inp);
186
+ const { idx16, idx16bg, idx256, rgb } = spec;
187
+ const [open, close] = bg ? [48, 49] : [38, 39];
188
+ return [
189
+ [[], []],
190
+ [[bg ? idx16bg : idx16], [close]],
191
+ [[open, 5, idx256], [close]],
192
+ [[open, 2, ...rgb], [close]]
193
+ ];
194
+ }
195
+ }
@@ -0,0 +1,127 @@
1
+ import tty from 'tty';
2
+
3
+ import { ColorDefineUtil, RGBInput } from './color-define';
4
+ import { ANSICodes } from './codes';
5
+ import { TermColorScheme, TermColorLevel, TermState, RGB } from './types';
6
+
7
+ export type TermStyle =
8
+ { text?: RGBInput, background?: RGBInput, italic?: boolean, underline?: boolean, inverse?: boolean, blink?: boolean };
9
+
10
+ export type TermStyleInput = TermStyle | RGBInput;
11
+ export type Prim = string | number | boolean | Date | RegExp;
12
+ export type TermColorPaletteInput = Record<string, TermStyleInput | [TermStyleInput, TermStyleInput]>;
13
+ export type TermColorFn = (text: Prim) => string;
14
+ export type TermColorPalette<T> = Record<keyof T, TermColorFn>;
15
+
16
+ const COLOR_LEVEL_MAP = { 1: 0, 4: 1, 8: 2, 24: 3 } as const;
17
+ type ColorBits = keyof (typeof COLOR_LEVEL_MAP);
18
+
19
+ /**
20
+ * Utils for colorizing output
21
+ */
22
+ export class ColorOutputUtil {
23
+
24
+ /**
25
+ * Detect color level from tty information
26
+ */
27
+ static async readTermColorLevel(stream: tty.WriteStream): Promise<TermColorLevel> {
28
+ const force = process.env.FORCE_COLOR;
29
+ const disable = process.env.NO_COLOR ?? process.env.NODE_DISABLE_COLORS;
30
+ if (force !== undefined) {
31
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
32
+ return Math.max(Math.min(/^\d+$/.test(force) ? parseInt(force, 10) : 1, 3), 0) as TermColorLevel;
33
+ } else if (disable !== undefined && /^(1|true|yes|on)/i.test(disable)) {
34
+ return 0;
35
+ }
36
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
37
+ return stream.isTTY ? COLOR_LEVEL_MAP[stream.getColorDepth() as ColorBits] : 0;
38
+ }
39
+
40
+ /**
41
+ * Read foreground/background color if env var is set
42
+ */
43
+ static async readBackgroundScheme(
44
+ query: () => Promise<RGB | undefined> | RGB | undefined,
45
+ env: string | undefined = process.env.COLORFGBG
46
+ ): Promise<TermColorScheme | undefined> {
47
+ let color = await query();
48
+ if (!color && env) {
49
+ const [, bg] = env.split(';');
50
+ color = ColorDefineUtil.rgbFromAnsi256(+bg);
51
+ }
52
+ if (color) {
53
+ const hex = `#${color.map(x => x.toString(16).padStart(2, '0')).join('')}` as const;
54
+ return ColorDefineUtil.defineColor(hex).scheme;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Get styled levels, 0-3
60
+ */
61
+ static getStyledLevels(inp: TermStyle | RGBInput): [string, string][] {
62
+ const cfg = (typeof inp !== 'object') ? { text: inp } : inp;
63
+ const levelPairs: [string, string][] = [['', '']];
64
+ const text = cfg.text ? ColorDefineUtil.getColorCodes(cfg.text, false) : undefined;
65
+ const bg = cfg.background ? ColorDefineUtil.getColorCodes(cfg.background, true) : undefined;
66
+
67
+ for (const level of [1, 2, 3]) {
68
+ const prefix: number[] = [];
69
+ const suffix: number[] = [];
70
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
71
+ for (const key of Object.keys(cfg) as (keyof typeof cfg)[]) {
72
+ if (!cfg[key]) {
73
+ continue;
74
+ }
75
+ switch (key) {
76
+ case 'inverse': prefix.push(7); suffix.push(27); break;
77
+ case 'underline': prefix.push(4); suffix.push(24); break;
78
+ case 'italic': prefix.push(3); suffix.push(23); break;
79
+ case 'blink': prefix.push(5); suffix.push(25); break;
80
+ case 'text': prefix.push(...text![level][0]); suffix.push(...text![level][1]); break;
81
+ case 'background': prefix.push(...bg![level][0]); suffix.push(...bg![level][1]); break;
82
+ }
83
+ }
84
+ levelPairs[level] = [
85
+ ANSICodes.STYLE(prefix),
86
+ ANSICodes.STYLE(suffix.reverse())
87
+ ];
88
+ }
89
+ return levelPairs;
90
+ }
91
+
92
+ /**
93
+ * Make a simple primitive colorer
94
+ */
95
+ static colorer(term: TermState, style: TermStyleInput | [light: TermStyleInput, dark: TermStyleInput]): TermColorFn {
96
+ const schemes = {
97
+ light: this.getStyledLevels(Array.isArray(style) ? style[1] ?? style[0] : style),
98
+ dark: this.getStyledLevels(Array.isArray(style) ? style[0] : style),
99
+ };
100
+ return (v: Prim): string => {
101
+ const [prefix, suffix] = schemes[term.backgroundScheme][term.colorLevel];
102
+ return (v === undefined || v === null) ? '' : `${prefix}${v}${suffix}`;
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Creates a color palette based on input styles
108
+ */
109
+ static palette<P extends TermColorPaletteInput>(term: TermState, input: P): TermColorPalette<P> {
110
+ // Common color support
111
+ const out: Partial<TermColorPalette<P>> = {};
112
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
113
+ for (const [k, col] of Object.entries(input) as [keyof P, TermStyleInput][]) {
114
+ out[k] = this.colorer(term, col);
115
+ }
116
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
117
+ return out as TermColorPalette<P>;
118
+ }
119
+
120
+ /**
121
+ * Convenience method to creates a color template function based on input styles
122
+ */
123
+ static templateFunction<P extends TermColorPaletteInput>(term: TermState, input: P): (key: keyof P, val: Prim) => string {
124
+ const pal = this.palette(term, input);
125
+ return (key: keyof P, val: Prim) => pal[key](val);
126
+ }
127
+ }
@@ -0,0 +1,100 @@
1
+ import timers from 'timers/promises';
2
+ import { DelayedConfig, Indexed } from './types';
3
+
4
+ export type StoppableIterable<T> = { stream: AsyncIterable<T>, stop: () => void };
5
+
6
+ export type MapFn<T, U> = (val: T, i: number) => U | Promise<U>;
7
+
8
+ const isIdx = (x: unknown): x is Indexed => (x !== undefined && x !== null) && typeof x === 'object' && 'idx' in x;
9
+
10
+ export class IterableUtil {
11
+
12
+ static DELAY = ({ initialDelay = 0, cycleDelay = 0 }: DelayedConfig = {}) =>
13
+ <T>(x: T, i: number): Promise<T> => timers.setTimeout(i === 0 ? initialDelay : cycleDelay).then(() => x);
14
+
15
+ static ORDERED = <T extends Indexed>(): (val: T) => boolean => {
16
+ let last = -1;
17
+ return (v: T): boolean => (last = Math.max(v.idx, last)) === v.idx;
18
+ };
19
+
20
+ static map<T, U, V, W>(source: AsyncIterable<T>, fn1: MapFn<T, U>, fn2: MapFn<U, V>, fn3: MapFn<V, W>): AsyncIterable<W>;
21
+ static map<T, U, V>(source: AsyncIterable<T>, fn1: MapFn<T, U>, fn2: MapFn<U, V>): AsyncIterable<V>;
22
+ static map<T, U>(source: AsyncIterable<T>, fn: MapFn<T, U>): AsyncIterable<U>;
23
+ static async * map<T>(source: AsyncIterable<T>, ...fns: MapFn<unknown, unknown>[]): AsyncIterable<unknown> {
24
+ let idx = -1;
25
+ for await (const el of source) {
26
+ if (el !== undefined) {
27
+ idx += 1;
28
+ let m = el;
29
+ for (const fn of fns) {
30
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
31
+ m = (await fn(m, idx)) as typeof m;
32
+ }
33
+ yield m;
34
+ }
35
+ }
36
+ }
37
+
38
+ static async * filter<T>(source: AsyncIterable<T>, pred: (val: T, i: number) => boolean | Promise<boolean>): AsyncIterable<T> {
39
+ let idx = -1;
40
+ for await (const el of source) {
41
+ if (await pred(el, idx += 1)) {
42
+ yield el;
43
+ }
44
+ }
45
+ }
46
+
47
+ static cycle<T>(items: T[]): StoppableIterable<T> {
48
+ let done = false;
49
+ async function* buildStream(): AsyncIterable<T> {
50
+ let i = -1;
51
+ while (!done) {
52
+ yield items[(i += 1) % items.length];
53
+ }
54
+ }
55
+ return { stream: buildStream(), stop: (): void => { done = true; } };
56
+ }
57
+
58
+ static async drain<T>(source: AsyncIterable<T>): Promise<T[]> {
59
+ const items: T[] = [];
60
+ for await (const ev of source) {
61
+ items[isIdx(ev) ? ev.idx : items.length] = ev;
62
+ }
63
+ return items;
64
+ }
65
+
66
+ static simpleQueue(): {
67
+ close: () => Promise<void>;
68
+ add: <T>(item: () => Promise<T>) => Promise<T>;
69
+ running: Promise<void>;
70
+ } {
71
+ let done = false;
72
+ let fire: () => void;
73
+ let next = new Promise<void>(r => fire = r);
74
+
75
+ const queue: Function[] = [];
76
+ async function run(): Promise<void> {
77
+ while (!done) {
78
+ if (!queue.length) {
79
+ await next;
80
+ next = new Promise(r => fire = r);
81
+ }
82
+ await queue.shift()?.();
83
+ }
84
+ }
85
+ const running = run();
86
+ return {
87
+ running,
88
+ close: (): Promise<void> => {
89
+ done = true;
90
+ fire();
91
+ return running;
92
+ },
93
+ add: <T>(fn: () => Promise<T>): Promise<T> => {
94
+ const prom = new Promise<T>(r => queue.push(() => fn().then(r)));
95
+ fire();
96
+ return prom;
97
+ }
98
+ };
99
+ }
100
+ }
@@ -0,0 +1,160 @@
1
+ export const NAMED_COLORS = {
2
+ // Name: RGB
3
+ stdBlack: 0x000000,
4
+ stdRed: 0xcd0000,
5
+ stdGreen: 0x00cd00,
6
+ stdYellow: 0xcdcd00,
7
+ stdBlue: 0x0000ee,
8
+ stdMagenta: 0xcd00cd,
9
+ stdCyan: 0x00cdcd,
10
+ stdWhite: 0xe5e5e5,
11
+ brightBlack: 0x7f7f7f,
12
+ brightRed: 0xff0000,
13
+ brightGreen: 0x00fc00,
14
+ brightYellow: 0xffff00,
15
+ brightBlue: 0x0000fc,
16
+ brightMagenta: 0xff00ff,
17
+ brightCyan: 0x00ffff,
18
+ brightWhite: 0xffffff,
19
+
20
+ aliceBlue: 0xf0f8ff,
21
+ antiqueWhite: 0xfaebd7,
22
+ aqua: 0x00ffff,
23
+ aquamarine: 0x7fffd4,
24
+ azure: 0xf0ffff,
25
+ beige: 0xf5f5dc,
26
+ bisque: 0xffe4c4,
27
+ black: 0x000000,
28
+ blanchedAlmond: 0xffebcd,
29
+ blue: 0x0000ff,
30
+ blueViolet: 0x8a2be2,
31
+ brown: 0xa52a2a,
32
+ burlyWood: 0xdeb887,
33
+ cadetBlue: 0x5f9ea0,
34
+ chartreuse: 0x7fff00,
35
+ chocolate: 0xd2691e,
36
+ coral: 0xff7f50,
37
+ cornflowerBlue: 0x6495ed,
38
+ cornSilk: 0xfff8dc,
39
+ crimson: 0xdc143c,
40
+ cyan: 0x00ffff,
41
+ darkBlue: 0x00008b,
42
+ darkCyan: 0x008b8b,
43
+ darkGoldenrod: 0xb8860b,
44
+ darkGray: 0xa9a9a9,
45
+ darkGreen: 0x006400,
46
+ darkKhaki: 0xbdb76b,
47
+ darkMagenta: 0x8b008b,
48
+ darkOliveGreen: 0x556b2f,
49
+ darkOrange: 0xff8c00,
50
+ darkOrchid: 0x9932cc,
51
+ darkRed: 0x8b0000,
52
+ darkSalmon: 0xe9967a,
53
+ darkSeaGreen: 0x8fbc8f,
54
+ darkSlateBlue: 0x483d8b,
55
+ darkSlateGray: 0x2f4f4f,
56
+ darkTurquoise: 0x00ced1,
57
+ darkViolet: 0x9400d3,
58
+ deepPink: 0xff1493,
59
+ deepSkyBlue: 0x00bfff,
60
+ dimGray: 0x696969,
61
+ dodgerBlue: 0x1e90ff,
62
+ firebrick: 0xb22222,
63
+ floralWhite: 0xfffaf0,
64
+ forestGreen: 0x228b22,
65
+ fuchsia: 0xff00ff,
66
+ gainsboro: 0xdcdcdc,
67
+ ghostWhite: 0xf8f8ff,
68
+ gold: 0xffd700,
69
+ goldenrod: 0xdaa520,
70
+ gray: 0x808080,
71
+ green: 0x008000,
72
+ greenYellow: 0xadff2f,
73
+ honeydew: 0xf0fff0,
74
+ hotPink: 0xff69b4,
75
+ indianRed: 0xcd5c5c,
76
+ indigo: 0x4b0082,
77
+ ivory: 0xfffff0,
78
+ khaki: 0xf0e68c,
79
+ lavender: 0xe6e6fa,
80
+ lavenderBlush: 0xfff0f5,
81
+ lawnGreen: 0x7cfc00,
82
+ lemonChiffon: 0xfffacd,
83
+ lightBlue: 0xadd8e6,
84
+ lightCoral: 0xf08080,
85
+ lightCyan: 0xe0ffff,
86
+ lightGoldenRodYellow: 0xfafad2,
87
+ lightGray: 0xd3d3d3,
88
+ lightGreen: 0x90ee90,
89
+ lightPink: 0xffb6c1,
90
+ lightSalmon: 0xffa07a,
91
+ lightSeaGreen: 0x20b2aa,
92
+ lightSkyBlue: 0x87cefa,
93
+ lightSlateGray: 0x778899,
94
+ lightSteelBlue: 0xb0c4de,
95
+ lightYellow: 0xffffe0,
96
+ lime: 0x00ff00,
97
+ limeGreen: 0x32cd32,
98
+ linen: 0xfaf0e6,
99
+ magenta: 0xff00ff,
100
+ maroon: 0x800000,
101
+ mediumAquamarine: 0x66cdaa,
102
+ mediumBlue: 0x0000cd,
103
+ mediumOrchid: 0xba55d3,
104
+ mediumPurple: 0x9370db,
105
+ mediumSeaGreen: 0x3cb371,
106
+ mediumSlateBlue: 0x7b68ee,
107
+ mediumSpringGreen: 0x00fa9a,
108
+ mediumTurquoise: 0x48d1cc,
109
+ mediumVioletRed: 0xc71585,
110
+ midnightBlue: 0x191970,
111
+ mintCream: 0xf5fffa,
112
+ mistyRose: 0xffe4e1,
113
+ moccasin: 0xffe4b5,
114
+ navajoWhite: 0xffdead,
115
+ navy: 0x000080,
116
+ oldLace: 0xfdf5e6,
117
+ olive: 0x808000,
118
+ oliveDrab: 0x6b8e23,
119
+ orange: 0xffa500,
120
+ orangeRed: 0xff4500,
121
+ orchid: 0xda70d6,
122
+ paleGoldenrod: 0xeee8aa,
123
+ paleGreen: 0x98fb98,
124
+ paleTurquoise: 0xafeeee,
125
+ paleVioletRed: 0xdb7093,
126
+ papayaWhip: 0xffefd5,
127
+ peachPuff: 0xffdab9,
128
+ peru: 0xcd853f,
129
+ pink: 0xffc0cb,
130
+ plum: 0xdda0dd,
131
+ powderBlue: 0xb0e0e6,
132
+ purple: 0x800080,
133
+ red: 0xff0000,
134
+ rosyBrown: 0xbc8f8f,
135
+ royalBlue: 0x4169e1,
136
+ saddleBrown: 0x8b4513,
137
+ salmon: 0xfa8072,
138
+ sandyBrown: 0xf4a460,
139
+ seaGreen: 0x2e8b57,
140
+ seashell: 0xfff5ee,
141
+ sienna: 0xa0522d,
142
+ silver: 0xc0c0c0,
143
+ skyBlue: 0x87ceeb,
144
+ slateBlue: 0x6a5acd,
145
+ slateGray: 0x708090,
146
+ snow: 0xfffafa,
147
+ springGreen: 0x00ff7f,
148
+ steelBlue: 0x4682b4,
149
+ tan: 0xd2b48c,
150
+ teal: 0x008080,
151
+ thistle: 0xd8bfd8,
152
+ tomato: 0xff6347,
153
+ turquoise: 0x40e0d0,
154
+ violet: 0xee82ee,
155
+ wheat: 0xf5deb3,
156
+ white: 0xffffff,
157
+ whitesmoke: 0xf5f5f5,
158
+ yellow: 0xffff00,
159
+ yellowGreen: 0x9acd32,
160
+ } as const;
@@ -0,0 +1,134 @@
1
+ import { IterableUtil } from './iterable';
2
+ import { TerminalWriter } from './writer';
3
+ import { Indexed, TerminalProgressRender, TerminalStreamingConfig, TerminalWaitingConfig, TermState } from './types';
4
+ import { ColorOutputUtil, TermStyleInput } from './color-output';
5
+
6
+ const STD_WAIT_STATES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'.split('');
7
+
8
+ export class TerminalOperation {
9
+
10
+ /**
11
+ * Allows for writing at top, bottom, or current position while new text is added
12
+ */
13
+ static async streamToPosition(term: TermState, source: AsyncIterable<string>, config: TerminalStreamingConfig = {}): Promise<void> {
14
+ const curPos = config.at ?? { ...await term.getCursorPosition() };
15
+ const pos = config.position ?? 'inline';
16
+
17
+ const writePos = pos === 'inline' ?
18
+ { ...curPos, x: 0 } :
19
+ { x: 0, y: pos === 'top' ? 0 : -1 };
20
+
21
+ try {
22
+ const batch = TerminalWriter.for(term).hideCursor();
23
+ if (pos !== 'inline') {
24
+ batch.storePosition().scrollRange(pos === 'top' ? { start: 2 } : { end: -2 }).restorePosition();
25
+ if (pos === 'top' && curPos.y === 0) {
26
+ batch.changePosition({ y: 1 }).write('');
27
+ } else if (pos === 'bottom' && curPos.y === term.height - 1) {
28
+ batch.changePosition({ y: -1 }).write('\n');
29
+ }
30
+ } else {
31
+ batch.write('\n'); // Move past line
32
+ }
33
+ await batch.commit();
34
+
35
+ for await (const text of source) {
36
+ await TerminalWriter.for(term).setPosition(writePos).write(text).clearLine(1).commit(true);
37
+ }
38
+ if (config.clearOnFinish ?? true) {
39
+ await TerminalWriter.for(term).setPosition(writePos).clearLine().commit(true);
40
+ }
41
+ } finally {
42
+ const finalCursor = await term.getCursorPosition();
43
+ await TerminalWriter.for(term).scrollRangeClear().setPosition(finalCursor).showCursor().commit();
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Consumes a stream, of events, tied to specific list indices, and updates in place
49
+ */
50
+ static async streamList(term: TermState, source: AsyncIterable<Indexed & { text: string }>): Promise<void> {
51
+ let max = 0;
52
+ try {
53
+ await TerminalWriter.for(term).hideCursor().commit();
54
+ for await (const { idx, text } of source) {
55
+ max = Math.max(idx, max);
56
+ await TerminalWriter.for(term).write('\n'.repeat(idx)).rewriteLine(text).clearLine(1).changePosition({ y: -idx }).commit();
57
+ }
58
+ } finally {
59
+ await TerminalWriter.for(term).changePosition({ y: max + 1 }).writeLine('\n').showCursor().commit();
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Waiting indicator, streamed to a specific position, can be canceled
65
+ */
66
+ static streamWaiting(term: TermState, message: string, config: TerminalWaitingConfig = {}): () => Promise<void> {
67
+ const { stop, stream } = IterableUtil.cycle(STD_WAIT_STATES);
68
+ const indicator = IterableUtil.map(
69
+ stream,
70
+ IterableUtil.DELAY(config),
71
+ (ch, i) => config.end ? `${message} ${ch}` : `${ch} ${message}`
72
+ );
73
+
74
+ const final = this.streamToPosition(term, indicator, config);
75
+ return async () => { stop(); return final; };
76
+ }
77
+
78
+ /**
79
+ * Build progress par formatter for terminal progress events
80
+ */
81
+ static buildProgressBar(term: TermState, style: TermStyleInput): TerminalProgressRender {
82
+ const color = ColorOutputUtil.colorer(term, style);
83
+ return ({ total, idx, text }): string => {
84
+ text ||= total ? '%idx/%total' : '%idx';
85
+
86
+ const totalStr = `${total ?? ''}`;
87
+ const idxStr = `${idx}`.padStart(totalStr.length);
88
+ const pct = total === undefined ? 0 : (idx / total);
89
+ const line = text
90
+ .replace(/%idx/, idxStr)
91
+ .replace(/%total/, totalStr)
92
+ .replace(/%pct/, `${Math.trunc(pct * 100)}`);
93
+ const full = ` ${line}`.padEnd(term.width);
94
+ const mid = Math.trunc(pct * term.width);
95
+ const [l, r] = [full.substring(0, mid), full.substring(mid)];
96
+ return `${color(l)}${r}`;
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Stream lines with a waiting indicator
102
+ */
103
+ static async streamLinesWithWaiting(term: TermState, lines: AsyncIterable<string | undefined>, cfg: TerminalWaitingConfig = {}): Promise<void> {
104
+ let writer: (() => Promise<unknown>) | undefined;
105
+ let line: string | undefined;
106
+
107
+ let pos = await term.getCursorPosition();
108
+
109
+
110
+ const commitLine = async (): Promise<void> => {
111
+ await writer?.();
112
+ if (line) {
113
+ const msg = cfg.committedPrefix ? `${cfg.committedPrefix} ${line}` : line;
114
+ if (cfg.position === 'inline') {
115
+ await TerminalWriter.for(term).setPosition(pos).write(msg).commit(true);
116
+ } else {
117
+ await TerminalWriter.for(term).writeLine(msg).commit();
118
+ }
119
+ line = undefined;
120
+ }
121
+ };
122
+
123
+ for await (let msg of lines) {
124
+ await commitLine();
125
+ if (msg !== undefined) {
126
+ msg = msg.replace(/\n$/, '');
127
+ pos = await term.getCursorPosition();
128
+ writer = this.streamWaiting(term, msg, { ...cfg, at: pos, clearOnFinish: false });
129
+ line = msg;
130
+ }
131
+ }
132
+ await commitLine();
133
+ }
134
+ }
package/src/query.ts ADDED
@@ -0,0 +1,96 @@
1
+ import tty from 'tty';
2
+
3
+ import { ANSICodes } from './codes';
4
+ import { IterableUtil } from './iterable';
5
+ import { RGB, TermCoord, TermQuery } from './types';
6
+
7
+ const to256 = (x: string): number => Math.trunc(parseInt(x, 16) / (16 ** (x.length - 2)));
8
+ const COLOR_RESPONSE = /(?<r>][0-9a-f]+)[/](?<g>[0-9a-f]+)[/](?<b>[0-9a-f]+)[/]?(?<a>[0-9a-f]+)?/i;
9
+
10
+ const ANSIQueries = {
11
+ /** Parse xterm color response */
12
+ color: (field: 'background' | 'foreground'): TermQuery<RGB | undefined> => ({
13
+ query: (): string => ANSICodes.OSC_QUERY(`${field}Color`),
14
+ response: (response: Buffer): RGB | undefined => {
15
+ const groups = response.toString('utf8').match(COLOR_RESPONSE)?.groups ?? {};
16
+ return 'r' in groups ? [to256(groups.r), to256(groups.g), to256(groups.b)] : undefined;
17
+ }
18
+ }),
19
+ /** Parse cursor query response into {x,y} */
20
+ cursorPosition: {
21
+ query: (): string => ANSICodes.DEVICE_STATUS_REPORT('cursorPosition'),
22
+ response: (response: Buffer): TermCoord => {
23
+ const groups = response.toString('utf8').match(/(?<r>\d*);(?<c>\d*)/)?.groups ?? {};
24
+ return 'c' in groups ? { x: +(groups.c) - 1, y: +(groups.r) - 1 } : { x: 0, y: 0 };
25
+ }
26
+ }
27
+ };
28
+
29
+ /**
30
+ * Terminal query support with centralized queuing for multiple writes to the same stream
31
+ */
32
+ export class TerminalQuerier {
33
+
34
+ static #cache = new Map<tty.ReadStream, TerminalQuerier>();
35
+
36
+ static for(input: tty.ReadStream, output: tty.WriteStream): TerminalQuerier {
37
+ if (!this.#cache.has(input)) {
38
+ this.#cache.set(input, new TerminalQuerier(input, output));
39
+ }
40
+ return this.#cache.get(input)!;
41
+ }
42
+
43
+ #queue = IterableUtil.simpleQueue();
44
+ #output: tty.WriteStream;
45
+ #input: tty.ReadStream;
46
+
47
+ constructor(input: tty.ReadStream, output: tty.WriteStream) {
48
+ this.#input = input;
49
+ this.#output = output;
50
+ }
51
+
52
+ async #readInput(query: string): Promise<Buffer> {
53
+ const isRaw = this.#input.isRaw;
54
+ const isPaused = this.#input.isPaused();
55
+ const data = this.#input.listeners('data');
56
+ try {
57
+ this.#input.removeAllListeners('data');
58
+
59
+ if (isPaused) {
60
+ this.#input.resume();
61
+ }
62
+
63
+ this.#input.setRawMode(true);
64
+ // Send data, but do not wait on it
65
+ this.#output.write(query);
66
+ await new Promise(res => this.#input.once('readable', res));
67
+ const val: Buffer | string = this.#input.read();
68
+ return typeof val === 'string' ? Buffer.from(val, 'utf8') : val;
69
+ } finally {
70
+ if (isPaused) {
71
+ this.#input.pause();
72
+ }
73
+ this.#input.setRawMode(isRaw);
74
+ for (const fn of data) {
75
+ // @ts-ignore
76
+ this.#input.on('data', fn);
77
+ }
78
+ }
79
+ }
80
+
81
+ query<T>(q: TermQuery<T>): Promise<T> {
82
+ return this.#queue.add(() => this.#readInput(q.query()).then(q.response));
83
+ }
84
+
85
+ cursorPosition(): Promise<TermCoord> {
86
+ return this.query(ANSIQueries.cursorPosition);
87
+ }
88
+
89
+ backgroundColor(): Promise<RGB | undefined> {
90
+ return this.query(ANSIQueries.color('background'));
91
+ }
92
+
93
+ close(): Promise<void> {
94
+ return this.#queue.close();
95
+ }
96
+ }
@@ -0,0 +1,207 @@
1
+ import tty from 'tty';
2
+
3
+ import { IterableUtil, MapFn } from './iterable';
4
+ import {
5
+ TermColorLevel, TermColorScheme, TerminalProgressEvent, TerminalTableConfig,
6
+ TerminalTableEvent, TerminalWaitingConfig, TermLinePosition, TermState, TermCoord
7
+ } from './types';
8
+ import { TerminalOperation } from './operation';
9
+ import { TerminalQuerier } from './query';
10
+ import { TerminalWriter } from './writer';
11
+ import { ColorOutputUtil, Prim, TermColorFn, TermColorPalette, TermColorPaletteInput, TermStyleInput } from './color-output';
12
+
13
+ type TerminalStreamPositionConfig = {
14
+ position?: TermLinePosition;
15
+ staticMessage?: string;
16
+ };
17
+
18
+ type TerminalProgressConfig = TerminalStreamPositionConfig & {
19
+ style?: TermStyleInput;
20
+ };
21
+
22
+ /**
23
+ * An enhanced tty write stream
24
+ */
25
+ export class Terminal implements TermState {
26
+
27
+ static async for(config: Partial<TermState>): Promise<Terminal> {
28
+ const term = new Terminal(config);
29
+ await term.init();
30
+ return term;
31
+ }
32
+
33
+ #init: Promise<void>;
34
+ #output: tty.WriteStream;
35
+ #input: tty.ReadStream;
36
+ #interactive: boolean;
37
+ #width?: number;
38
+ #backgroundScheme?: TermColorScheme;
39
+ #colorLevel?: TermColorLevel;
40
+ #query: TerminalQuerier;
41
+
42
+ constructor(config: Partial<TermState>) {
43
+ this.#output = config.output ?? process.stdout;
44
+ this.#input = config.input ?? process.stdin;
45
+ this.#interactive = config.interactive ?? (this.#output.isTTY && !/^(true|yes|on|1)$/i.test(process.env.TRV_QUIET ?? ''));
46
+ this.#width = config.width;
47
+ this.#colorLevel = config.colorLevel;
48
+ this.#backgroundScheme = config.backgroundScheme;
49
+ this.#query = TerminalQuerier.for(this.#input, this.#output);
50
+ }
51
+
52
+ get output(): tty.WriteStream {
53
+ return this.#output;
54
+ }
55
+
56
+ get input(): tty.ReadStream {
57
+ return this.#input;
58
+ }
59
+
60
+ get interactive(): boolean {
61
+ return this.#interactive;
62
+ }
63
+
64
+ get width(): number {
65
+ return this.#width ?? (this.#output.isTTY ? this.#output.columns : 120);
66
+ }
67
+
68
+ get height(): number {
69
+ return (this.#output.isTTY ? this.#output.rows : 120);
70
+ }
71
+
72
+ get colorLevel(): TermColorLevel {
73
+ return this.#colorLevel ?? 0;
74
+ }
75
+
76
+ get backgroundScheme(): TermColorScheme {
77
+ return this.#backgroundScheme ?? 'dark';
78
+ }
79
+
80
+ writer(): TerminalWriter {
81
+ return TerminalWriter.for(this);
82
+ }
83
+
84
+ async writeLines(...text: string[]): Promise<void> {
85
+ return this.writer().writeLines(text, this.interactive).commit();
86
+ }
87
+
88
+ async init(): Promise<void> {
89
+ if (!this.#init) {
90
+ this.#init = (async (): Promise<void> => {
91
+ this.#colorLevel ??= await ColorOutputUtil.readTermColorLevel(this.#output);
92
+ this.#backgroundScheme ??= await ColorOutputUtil.readBackgroundScheme(
93
+ () => this.interactive ? this.#query.backgroundColor() : undefined
94
+ );
95
+ })();
96
+ process.on('exit', () => this.reset());
97
+ }
98
+ return this.#init;
99
+ }
100
+
101
+ async reset(): Promise<void> {
102
+ await this.#query.close();
103
+ if (this.interactive) {
104
+ await this.writer().reset().commit();
105
+ }
106
+ return;
107
+ }
108
+
109
+ getCursorPosition(): Promise<TermCoord> {
110
+ return this.#query.cursorPosition();
111
+ }
112
+
113
+ /**
114
+ * Waiting message with a callback to end
115
+ */
116
+ async withWaiting<T>(message: string, work: Promise<T> | (() => Promise<T>), config: TerminalWaitingConfig = {}): Promise<T> {
117
+ const res = (typeof work === 'function' ? work() : work);
118
+ if (!this.interactive) {
119
+ await this.writeLines(`${message}...`);
120
+ return res;
121
+ }
122
+ return res.finally(TerminalOperation.streamWaiting(this, message, config));
123
+ }
124
+
125
+ /**
126
+ * Stream line output, showing a waiting indicator for each line until the next one occurs
127
+ *
128
+ * @param lines
129
+ * @param config
130
+ * @returns
131
+ */
132
+ async streamLinesWithWaiting(lines: AsyncIterable<string | undefined>, config: TerminalWaitingConfig): Promise<void> {
133
+ if (!this.interactive) {
134
+ for await (const line of lines) {
135
+ if (line !== undefined) {
136
+ const out = config.committedPrefix ? `${config.committedPrefix} ${line}` : line;
137
+ await this.writeLines(out);
138
+ }
139
+ }
140
+ } else {
141
+ return TerminalOperation.streamLinesWithWaiting(this, lines, { position: 'bottom', ...config });
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Consumes a stream, of events, tied to specific list indices, and updates in place
147
+ */
148
+ async streamList<T>(source: AsyncIterable<T>, resolve: MapFn<T, TerminalTableEvent>, config: TerminalTableConfig = {}): Promise<void> {
149
+ const resolved = IterableUtil.map(source, resolve);
150
+
151
+ await this.writeLines(...(config.header ?? []));
152
+
153
+ if (!this.interactive) {
154
+ const isDone = IterableUtil.filter(resolved, ev => !!ev.done);
155
+ if (config.forceNonInteractiveOrder) {
156
+ await this.writeLines(...(await IterableUtil.drain(isDone)).map(x => x.text), '');
157
+ } else {
158
+ await IterableUtil.drain(IterableUtil.map(isDone, ev => this.writeLines(ev.text)));
159
+ await this.writeLines('');
160
+ }
161
+ return;
162
+ }
163
+
164
+ await TerminalOperation.streamList(this, resolved);
165
+ }
166
+
167
+ /**
168
+ * Streams an iterable to a specific location, with support for non-interactive ttys
169
+ */
170
+ async streamToPosition<T>(source: AsyncIterable<T>, resolve: MapFn<T, string>, config?: TerminalStreamPositionConfig): Promise<void> {
171
+ if (!this.interactive) {
172
+ if (config?.staticMessage) {
173
+ await this.writeLines(config.staticMessage);
174
+ }
175
+ await IterableUtil.drain(source);
176
+ return;
177
+ }
178
+ return TerminalOperation.streamToPosition(this, IterableUtil.map(source, resolve), config);
179
+ }
180
+
181
+ /**
182
+ * Track progress of an asynchronous iterator, allowing the showing of a progress bar if the stream produces idx and total
183
+ */
184
+ async trackProgress<T, V extends TerminalProgressEvent>(
185
+ source: AsyncIterable<T>, resolve: MapFn<T, V>, config?: TerminalProgressConfig
186
+ ): Promise<void> {
187
+ const render = TerminalOperation.buildProgressBar(this, config?.style ?? { background: 'limeGreen', text: 'black' });
188
+ return this.streamToPosition(source, async (v, i) => render(await resolve(v, i)), config);
189
+ }
190
+
191
+ /** Creates a colorer function */
192
+ colorer(style: TermStyleInput | [light: TermStyleInput, dark: TermStyleInput]): TermColorFn {
193
+ return ColorOutputUtil.colorer(this, style);
194
+ }
195
+
196
+ /** Creates a color palette based on input styles */
197
+ palette<P extends TermColorPaletteInput>(input: P): TermColorPalette<P> {
198
+ return ColorOutputUtil.palette(this, input);
199
+ }
200
+
201
+ /** Convenience method to creates a color template function based on input styles */
202
+ templateFunction<P extends TermColorPaletteInput>(input: P): (key: keyof P, val: Prim) => string {
203
+ return ColorOutputUtil.templateFunction(this, input);
204
+ }
205
+ }
206
+
207
+ export const GlobalTerminal = new Terminal({ output: process.stdout });
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ import tty from 'tty';
2
+
3
+ export type Indexed = { idx: number };
4
+ export type DelayedConfig = { initialDelay?: number, cycleDelay?: number };
5
+
6
+ type I = number;
7
+ export type RGB = [r: I, g: I, b: I] | (readonly [r: I, g: I, b: I]);
8
+ export type TermCoord = { x: number, y: number };
9
+ export type TermLinePosition = 'top' | 'bottom' | 'inline';
10
+ export type TermColorField = 'foregroundColor' | 'backgroundColor';
11
+
12
+ export type TerminalTableEvent = { idx: number, text: string, done?: boolean };
13
+ export type TerminalTableConfig = { header?: string[], forceNonInteractiveOrder?: boolean };
14
+ export type TerminalProgressEvent = { idx: number, total?: number, text?: string };
15
+ export type TerminalProgressRender = (ev: TerminalProgressEvent) => string;
16
+ export type TerminalStreamingConfig = { position?: TermLinePosition, clearOnFinish?: boolean, at?: TermCoord };
17
+ export type TerminalWaitingConfig = { end?: boolean, committedPrefix?: string } & TerminalStreamingConfig & DelayedConfig;
18
+
19
+ export type TermColorLevel = 0 | 1 | 2 | 3;
20
+ export type TermColorScheme = 'dark' | 'light';
21
+
22
+ export type TermState = {
23
+ interactive: boolean;
24
+ height: number;
25
+ width: number;
26
+ input: tty.ReadStream;
27
+ output: tty.WriteStream;
28
+ colorLevel: TermColorLevel;
29
+ backgroundScheme: TermColorScheme;
30
+ getCursorPosition(): Promise<TermCoord>;
31
+ };
32
+
33
+ export type TermQuery<T> = { query: () => string, response: (inp: Buffer) => T };
package/src/writer.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { ANSICodes } from './codes';
2
+ import { TermCoord, TermState } from './types';
3
+
4
+ const boundIndex = (v: number, size: number): number => {
5
+ if (v < 0) {
6
+ v = size + v;
7
+ }
8
+ return Math.max(Math.min(v, size - 1), 0);
9
+ };
10
+
11
+ /**
12
+ * Buffered/batched writer. Meant to be similar to readline.Readline, but with more general writing support and extensibility
13
+ */
14
+ export class TerminalWriter {
15
+
16
+ static for(term: TermState): TerminalWriter {
17
+ return new TerminalWriter(term);
18
+ }
19
+
20
+ #queue: (string | number)[] = [];
21
+ #term: TermState;
22
+ #restoreOnCommit = false;
23
+
24
+ constructor(term: TermState) {
25
+ this.#term = term;
26
+ }
27
+
28
+ /** Restore on commit */
29
+ restoreOnCommit(): this {
30
+ this.#restoreOnCommit = true;
31
+ return this;
32
+ }
33
+
34
+ commit(restorePosition: boolean = this.#restoreOnCommit): Promise<void> {
35
+ const q = this.#queue.filter(x => x !== undefined);
36
+ this.#queue = [];
37
+ if (q.length && restorePosition) {
38
+ q.unshift(ANSICodes.POSITION_SAVE());
39
+ q.push(ANSICodes.POSITION_RESTORE());
40
+ }
41
+ return q.length ? new Promise(r => this.#term.output.write(q.join(''), () => r())) : Promise.resolve();
42
+ }
43
+
44
+ write(...text: (string | number)[]): this {
45
+ this.#queue.push(...text);
46
+ return this;
47
+ }
48
+
49
+ /** Stores current cursor position, if called multiple times before restore, last one ones */
50
+ storePosition(): this {
51
+ return this.write(ANSICodes.POSITION_SAVE());
52
+ }
53
+
54
+ /** Restores cursor position, will not behave correctly if nested */
55
+ restorePosition(): this {
56
+ return this.write(ANSICodes.POSITION_RESTORE());
57
+ }
58
+
59
+ /** Rewrite a single line in the stream */
60
+ rewriteLine(text: string): this {
61
+ return this.setPosition({ x: 0 }).write(text);
62
+ }
63
+
64
+ /** Clear line, -1 (left), 0 (both), 1 (right), from current cursor */
65
+ clearLine(dir: -1 | 0 | 1 = 0): this {
66
+ return this.write(ANSICodes.ERASE_LINE(dir === 0 ? 2 : (dir === 1 ? 0 : 1)));
67
+ }
68
+
69
+ /** Set position */
70
+ setPosition({ x = 0, y }: Partial<TermCoord>): this {
71
+ if (y !== undefined) {
72
+ y = boundIndex(y, this.#term.output.rows);
73
+ }
74
+ x = boundIndex(x, this.#term.output.columns);
75
+ if (y !== undefined) {
76
+ return this.write(ANSICodes.POSITION_SET(y + 1, x + 1));
77
+ } else {
78
+ return this.write(ANSICodes.COLUMN_SET(x + 1));
79
+ }
80
+ }
81
+
82
+ /** Relative movement */
83
+ changePosition({ x, y }: Partial<TermCoord>): this {
84
+ if (x) {
85
+ this.write(ANSICodes.CURSOR_DX(x));
86
+ }
87
+ if (y) {
88
+ this.write(ANSICodes.CURSOR_DY(y));
89
+ }
90
+ return this;
91
+ }
92
+
93
+ /** Write single line */
94
+ writeLine(line: string = ''): this {
95
+ return this.write(`${line}\n`);
96
+ }
97
+
98
+ /** Write multiple lines */
99
+ writeLines(lines: (string | undefined)[], clear = false): this {
100
+ lines = lines.filter(x => x !== undefined);
101
+ let text = lines.join('\n');
102
+ if (text.length > 0) {
103
+ if (clear) {
104
+ text = text.replaceAll('\n', `${ANSICodes.ERASE_LINE(0)}\n`);
105
+ }
106
+ text = `${text}\n`;
107
+ }
108
+ return this.write(text);
109
+ }
110
+
111
+ /** Show cursor */
112
+ showCursor(): this {
113
+ return this.write(ANSICodes.SHOW_CURSOR());
114
+ }
115
+
116
+ /** Hide cursor */
117
+ hideCursor(): this {
118
+ return this.write(ANSICodes.HIDE_CURSOR());
119
+ }
120
+
121
+ /** Set scrolling range */
122
+ scrollRange({ start, end }: { start?: number, end?: number }): this {
123
+ start = boundIndex(start ?? 0, this.#term.output.rows);
124
+ end = boundIndex(end ?? -1, this.#term.output.rows);
125
+ return this.write(ANSICodes.SCROLL_RANGE_SET(start + 1, end + 1));
126
+ }
127
+
128
+ /** Clear scrolling range */
129
+ scrollRangeClear(): this {
130
+ return this.write(ANSICodes.SCROLL_RANGE_CLEAR());
131
+ }
132
+
133
+ /** Scrolling window y <=0 - up, else down */
134
+ scrollY(y: number): this {
135
+ return this.write(ANSICodes.SCROLL_WINDOW(y));
136
+ }
137
+
138
+ /** Reset */
139
+ reset(): this {
140
+ return this
141
+ .write(ANSICodes.POSITION_SAVE())
142
+ .write(ANSICodes.SHOW_CURSOR())
143
+ .write(ANSICodes.SCROLL_RANGE_CLEAR())
144
+ .write(ANSICodes.POSITION_RESTORE());
145
+ }
146
+ }