Skip to content

feat(core+prompts): Refactoring of the frame renderer #302

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"test": "vitest run"
},
"dependencies": {
"@macfja/ansi": "^1.0.0",
"picocolors": "^1.0.0",
"sisteransi": "^1.0.5"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ export { default as Prompt } from './prompts/prompt.js';
export { default as SelectPrompt } from './prompts/select.js';
export { default as SelectKeyPrompt } from './prompts/select-key.js';
export { default as TextPrompt } from './prompts/text.js';
export { block, isCancel, getColumns } from './utils/index.js';
export { block, isCancel, getColumns, frameRenderer, appendRenderer } from './utils/index.js';
export { updateSettings, settings } from './utils/settings.js';
52 changes: 12 additions & 40 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import { Writable } from 'node:stream';
import { cursor, erase } from 'sisteransi';
import wrap from 'wrap-ansi';

import { CANCEL_SYMBOL, diffLines, isActionKey, setRawMode, settings } from '../utils/index.js';
import {
CANCEL_SYMBOL,
diffLines,
frameRenderer,
isActionKey,
setRawMode,
settings,
} from '../utils/index.js';

import type { ClackEvents, ClackState } from '../types.js';
import type { Action } from '../utils/index.js';
Expand All @@ -28,9 +35,9 @@ export default class Prompt {

private rl: ReadLine | undefined;
private opts: Omit<PromptOptions<Prompt>, 'render' | 'input' | 'output'>;
private renderer: (frame: string) => void;
private _render: (context: Omit<Prompt, 'prompt'>) => string | undefined;
private _track = false;
private _prevFrame = '';
private _subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>();
protected _cursor = 0;

Expand All @@ -51,6 +58,7 @@ export default class Prompt {

this.input = input;
this.output = output;
this.renderer = frameRenderer(output);
}

/**
Expand Down Expand Up @@ -246,51 +254,15 @@ export default class Prompt {
this.unsubscribe();
}

private restoreCursor() {
const lines =
wrap(this._prevFrame, process.stdout.columns, { hard: true }).split('\n').length - 1;
this.output.write(cursor.move(-999, lines * -1));
}

private render() {
const frame = wrap(this._render(this) ?? '', process.stdout.columns, { hard: true });
if (frame === this._prevFrame) return;

if (this.state === 'initial') {
this.output.write(cursor.hide);
} else {
const diff = diffLines(this._prevFrame, frame);
this.restoreCursor();
// If a single line has changed, only update that line
if (diff && diff?.length === 1) {
const diffLine = diff[0];
this.output.write(cursor.move(0, diffLine));
this.output.write(erase.lines(1));
const lines = frame.split('\n');
this.output.write(lines[diffLine]);
this._prevFrame = frame;
this.output.write(cursor.move(0, lines.length - diffLine - 1));
return;
// If many lines have changed, rerender everything past the first line
}
if (diff && diff?.length > 1) {
const diffLine = diff[0];
this.output.write(cursor.move(0, diffLine));
this.output.write(erase.down());
const lines = frame.split('\n');
const newLines = lines.slice(diffLine);
this.output.write(newLines.join('\n'));
this._prevFrame = frame;
return;
}

this.output.write(erase.down());
}

this.output.write(frame);
this.renderer(this._render(this) ?? '');

if (this.state === 'initial') {
this.state = 'active';
}
this._prevFrame = frame;
}
}
94 changes: 94 additions & 0 deletions packages/core/src/utils/display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Writable } from 'node:stream';
import { stripAnsi, wrap as wrapAnsi } from '@macfja/ansi';
import { cursor, erase } from 'sisteransi';
import wrap from 'wrap-ansi';
import { getColumns } from './index.js';
import { diffLines } from './string.js';

function restoreCursor(output: Writable, prevFrame: string) {
const lines = prevFrame.split('\n').length - 1;
output.write(cursor.move(-999, lines * -1));
}

function renderFrame(newFrame: string, prevFrame: string, output: Writable): string {
const frame = wrap(newFrame, getColumns(output), { hard: true });
if (frame === prevFrame) return frame;

if (prevFrame === '') {
output.write(frame);
return frame;
}

const diff = diffLines(prevFrame, frame);
restoreCursor(output, prevFrame);
// If a single line has changed, only update that line
if (diff && diff?.length === 1) {
const diffLine = diff[0];
output.write(cursor.move(0, diffLine));
output.write(erase.lines(1));
const lines = frame.split('\n');
output.write(lines[diffLine]);
output.write(cursor.move(0, lines.length - diffLine - 1));
return frame;
}
// If many lines have changed, rerender everything past the first line
if (diff && diff?.length > 1) {
const diffLine = diff[0];
output.write(cursor.move(0, diffLine));
output.write(erase.down());
const lines = frame.split('\n');
const newLines = lines.slice(diffLine);
output.write(newLines.join('\n'));
return frame;
}

output.write(erase.down());
output.write(frame);

return frame;
}

/**
* Create a function to render a frame base on the previous call (don't redraw lines that didn't change between 2 calls).
*
* @param output The Writable where to render
* @return The rendering function to call with the new frame to display
*/
export function frameRenderer(output: Writable): (frame: string) => void {
let prevFrame = '';
return (frame: string) => {
prevFrame = renderFrame(frame, prevFrame, output);
};
}

/**
* Create a function to render the next part of a sentence.
* It will automatically wrap (without, if possible, breaking word).
*
* @param output The Writable where to render
* @param joiner The prefix to put in front of each lines
* @param removeLeadingSpace if `true` leading space of new lines will be removed
* @return The rendering function to call with the next part of the content
*/
export function appendRenderer(
output: Writable,
joiner: string,
removeLeadingSpace = true
): (next: string) => void {
let lastLine = joiner;
const joinerLength = stripAnsi(joiner).length + 1;
const newLineRE = removeLeadingSpace ? /\n */g : /\n/g;

return (next: string) => {
const width = getColumns(output) - joinerLength;
const lines =
lastLine.substring(0, joiner.length) +
wrapAnsi(`${lastLine.substring(joiner.length)}${next}`, width).replace(
newLineRE,
`\n${joiner}`
);
output?.write(cursor.move(-999, 0) + erase.lines(1));
output?.write(lines);
lastLine = lines.substring(Math.max(0, lines.lastIndexOf('\n') + 1));
};
}
1 change: 1 addition & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isActionKey } from './settings.js';

export * from './string.js';
export * from './settings.js';
export * from './display.js';

const isWindows = globalThis.process.platform.startsWith('win');

Expand Down
30 changes: 8 additions & 22 deletions packages/prompts/src/spinner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { block, settings } from '@clack/core';
import { block, frameRenderer, settings } from '@clack/core';
import color from 'picocolors';
import { cursor, erase } from 'sisteransi';
import {
type CommonOptions,
S_BAR,
Expand Down Expand Up @@ -34,13 +33,13 @@ export const spinner = ({
const frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'];
const delay = unicode ? 80 : 120;
const isCI = process.env.CI === 'true';
const renderer = frameRenderer(output);

let unblock: () => void;
let loop: NodeJS.Timeout;
let isSpinnerActive = false;
let isCancelled = false;
let _message = '';
let _prevMessage: string | undefined = undefined;
let _origin: number = performance.now();

const handleExit = (code: number) => {
Expand Down Expand Up @@ -79,14 +78,6 @@ export const spinner = ({
process.removeListener('exit', handleExit);
};

const clearPrevMessage = () => {
if (_prevMessage === undefined) return;
if (isCI) output.write('\n');
const prevLines = _prevMessage.split('\n');
output.write(cursor.move(-999, prevLines.length - 1));
output.write(erase.down(prevLines.length));
};

const removeTrailingDots = (msg: string): string => {
return msg.replace(/\.+$/, '');
};
Expand All @@ -108,20 +99,15 @@ export const spinner = ({
let indicatorTimer = 0;
registerHooks();
loop = setInterval(() => {
if (isCI && _message === _prevMessage) {
return;
}
clearPrevMessage();
_prevMessage = _message;
const frame = color.magenta(frames[frameIndex]);

if (isCI) {
output.write(`${frame} ${_message}...`);
renderer(`${frame} ${_message}...`);
} else if (indicator === 'timer') {
output.write(`${frame} ${_message} ${formatTimer(_origin)}`);
renderer(`${frame} ${_message} ${formatTimer(_origin)}`);
} else {
const loadingDots = '.'.repeat(Math.floor(indicatorTimer)).slice(0, 3);
output.write(`${frame} ${_message}${loadingDots}`);
renderer(`${frame} ${_message}${loadingDots}`);
}

frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0;
Expand All @@ -132,7 +118,6 @@ export const spinner = ({
const stop = (msg = '', code = 0): void => {
isSpinnerActive = false;
clearInterval(loop);
clearPrevMessage();
const step =
code === 0
? color.green(S_STEP_SUBMIT)
Expand All @@ -141,10 +126,11 @@ export const spinner = ({
: color.red(S_STEP_ERROR);
_message = msg ?? _message;
if (indicator === 'timer') {
output.write(`${step} ${_message} ${formatTimer(_origin)}\n`);
renderer(`${step} ${_message} ${formatTimer(_origin)}`);
} else {
output.write(`${step} ${_message}\n`);
renderer(`${step} ${_message}`);
}
output?.write('\n');
clearHooks();
unblock();
};
Expand Down
30 changes: 7 additions & 23 deletions packages/prompts/src/stream.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,21 @@
import { stripVTControlCharacters as strip } from 'node:util';
import { appendRenderer } from '@clack/core';
import color from 'picocolors';
import { S_BAR, S_ERROR, S_INFO, S_STEP_SUBMIT, S_SUCCESS, S_WARN } from './common.js';
import type { LogMessageOptions } from './log.js';

const prefix = `${color.gray(S_BAR)} `;

// TODO (43081j): this currently doesn't support custom `output` writables
// because we rely on `columns` existing (i.e. `process.stdout.columns).
//
// If we want to support `output` being passed in, we will need to use
// a condition like `if (output insance Writable)` to check if it has columns
export const stream = {
message: async (
iterable: Iterable<string> | AsyncIterable<string>,
{ symbol = color.gray(S_BAR) }: LogMessageOptions = {}
{ symbol = color.gray(S_BAR), output = process.stdout }: LogMessageOptions = {}
) => {
process.stdout.write(`${color.gray(S_BAR)}\n${symbol} `);
let lineWidth = 3;
for await (let chunk of iterable) {
chunk = chunk.replace(/\n/g, `\n${prefix}`);
if (chunk.includes('\n')) {
lineWidth = 3 + strip(chunk.slice(chunk.lastIndexOf('\n'))).length;
}
const chunkLen = strip(chunk).length;
if (lineWidth + chunkLen < process.stdout.columns) {
lineWidth += chunkLen;
process.stdout.write(chunk);
} else {
process.stdout.write(`\n${prefix}${chunk.trimStart()}`);
lineWidth = 3 + strip(chunk.trimStart()).length;
}
output.write(`${color.gray(S_BAR)}\n${symbol} `);
const renderer = appendRenderer(output, prefix);
for await (const chunk of iterable) {
renderer(chunk);
}
process.stdout.write('\n');
output.write('\n');
},
info: (iterable: Iterable<string> | AsyncIterable<string>) => {
return stream.message(iterable, { symbol: color.blue(S_INFO) });
Expand Down
Loading