Skip to content

feat(@clack/core,@clack/prompts): improve types event emitter & global aliases #147

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

Merged
merged 5 commits into from
Dec 14, 2024
Merged
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
5 changes: 5 additions & 0 deletions .changeset/swift-jars-destroy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clack/core': patch
---

Improves types for events and interaction states.
8 changes: 8 additions & 0 deletions examples/basic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ async function main() {

await setTimeout(1000);

p.setGlobalAliases([
['w', 'up'],
['s', 'down'],
['a', 'left'],
['d', 'right'],
['escape', 'cancel'],
]);

p.intro(`${color.bgCyan(color.black(' create-app '))}`);

const project = await p.group(
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ export { default as ConfirmPrompt } from './prompts/confirm';
export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect';
export { default as MultiSelectPrompt } from './prompts/multi-select';
export { default as PasswordPrompt } from './prompts/password';
export { default as Prompt, isCancel } from './prompts/prompt';
export type { State } from './prompts/prompt';
export { default as Prompt } from './prompts/prompt';
export { default as SelectPrompt } from './prompts/select';
export { default as SelectKeyPrompt } from './prompts/select-key';
export { default as TextPrompt } from './prompts/text';
export { block } from './utils';
export type { ClackState as State } from './types';
export { block, isCancel, setGlobalAliases } from './utils';

157 changes: 80 additions & 77 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,13 @@
import type { Key, ReadLine } from 'node:readline';

import { stdin, stdout } from 'node:process';
import readline from 'node:readline';
import readline, { type Key, type ReadLine } from 'node:readline';
import { Readable, Writable } from 'node:stream';
import { WriteStream } from 'node:tty';
import { cursor, erase } from 'sisteransi';
import wrap from 'wrap-ansi';

function diffLines(a: string, b: string) {
if (a === b) return;

const aLines = a.split('\n');
const bLines = b.split('\n');
const diff: number[] = [];

for (let i = 0; i < Math.max(aLines.length, bLines.length); i++) {
if (aLines[i] !== bLines[i]) diff.push(i);
}

return diff;
}

const cancel = Symbol('clack:cancel');
export function isCancel(value: unknown): value is symbol {
return value === cancel;
}

function setRawMode(input: Readable, value: boolean) {
if ((input as typeof stdin).isTTY) (input as typeof stdin).setRawMode(value);
}
import { ALIASES, CANCEL_SYMBOL, diffLines, hasAliasKey, KEYS, setRawMode } from '../utils';

const aliases = new Map([
['k', 'up'],
['j', 'down'],
['h', 'left'],
['l', 'right'],
]);
const keys = new Set(['up', 'down', 'left', 'right', 'space', 'enter']);
import type { ClackEvents, ClackState, InferSetType } from '../types';

export interface PromptOptions<Self extends Prompt> {
render(this: Omit<Self, 'prompt'>): string | void;
Expand All @@ -48,25 +19,25 @@ export interface PromptOptions<Self extends Prompt> {
debug?: boolean;
}

export type State = 'initial' | 'active' | 'cancel' | 'submit' | 'error';

export default class Prompt {
protected input: Readable;
protected output: Writable;

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

public state: State = 'initial';
public state: ClackState = 'initial';
public error = '';
public value: any;
public error: string = '';

constructor(
{ render, input = stdin, output = stdout, ...opts }: PromptOptions<Prompt>,
trackValue: boolean = true
) {
constructor(options: PromptOptions<Prompt>, trackValue: boolean = true) {
const { input = stdin, output = stdout, render, ...opts } = options;

this.opts = opts;
this.onKeypress = this.onKeypress.bind(this);
this.close = this.close.bind(this);
Expand All @@ -78,6 +49,66 @@ export default class Prompt {
this.output = output;
}

/**
* Unsubscribe all listeners
*/
protected unsubscribe() {
this._subscribers.clear();
}

/**
* Set a subscriber with opts
* @param event - The event name
*/
private setSubscriber<T extends keyof ClackEvents>(
event: T,
opts: { cb: ClackEvents[T]; once?: boolean }
) {
const params = this._subscribers.get(event) ?? [];
params.push(opts);
this._subscribers.set(event, params);
}

/**
* Subscribe to an event
* @param event - The event name
* @param cb - The callback
*/
public on<T extends keyof ClackEvents>(event: T, cb: ClackEvents[T]) {
this.setSubscriber(event, { cb });
}

/**
* Subscribe to an event once
* @param event - The event name
* @param cb - The callback
*/
public once<T extends keyof ClackEvents>(event: T, cb: ClackEvents[T]) {
this.setSubscriber(event, { cb, once: true });
}

/**
* Emit an event with data
* @param event - The event name
* @param data - The data to pass to the callback
*/
public emit<T extends keyof ClackEvents>(event: T, ...data: Parameters<ClackEvents[T]>) {
const cbs = this._subscribers.get(event) ?? [];
const cleanup: (() => void)[] = [];

for (const subscriber of cbs) {
subscriber.cb(...data);

if (subscriber.once) {
cleanup.push(() => cbs.splice(cbs.indexOf(subscriber), 1));
}
}

for (const cb of cleanup) {
cb();
}
}

public prompt() {
const sink = new WriteStream(0);
sink._write = (chunk, encoding, done) => {
Expand Down Expand Up @@ -120,48 +151,20 @@ export default class Prompt {
this.output.write(cursor.show);
this.output.off('resize', this.render);
setRawMode(this.input, false);
resolve(cancel);
resolve(CANCEL_SYMBOL);
});
});
}

private subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>();
public on(event: string, cb: (...args: any) => any) {
const arr = this.subscribers.get(event) ?? [];
arr.push({ cb });
this.subscribers.set(event, arr);
}
public once(event: string, cb: (...args: any) => any) {
const arr = this.subscribers.get(event) ?? [];
arr.push({ cb, once: true });
this.subscribers.set(event, arr);
}
public emit(event: string, ...data: any[]) {
const cbs = this.subscribers.get(event) ?? [];
const cleanup: (() => void)[] = [];
for (const subscriber of cbs) {
subscriber.cb(...data);
if (subscriber.once) {
cleanup.push(() => cbs.splice(cbs.indexOf(subscriber), 1));
}
}
for (const cb of cleanup) {
cb();
}
}
private unsubscribe() {
this.subscribers.clear();
}

private onKeypress(char: string, key?: Key) {
if (this.state === 'error') {
this.state = 'active';
}
if (key?.name && !this._track && aliases.has(key.name)) {
this.emit('cursor', aliases.get(key.name));
if (key?.name && !this._track && ALIASES.has(key.name)) {
this.emit('cursor', ALIASES.get(key.name));
}
if (key?.name && keys.has(key.name)) {
this.emit('cursor', key.name);
if (key?.name && KEYS.has(key.name as InferSetType<typeof KEYS>)) {
this.emit('cursor', key.name as InferSetType<typeof KEYS>);
}
if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) {
this.emit('confirm', char.toLowerCase() === 'y');
Expand Down Expand Up @@ -189,7 +192,8 @@ export default class Prompt {
this.state = 'submit';
}
}
if (char === '\x03') {

if (hasAliasKey([key?.name, key?.sequence], 'cancel')) {
this.state = 'cancel';
}
if (this.state === 'submit' || this.state === 'cancel') {
Expand Down Expand Up @@ -217,7 +221,6 @@ export default class Prompt {
this.output.write(cursor.move(-999, lines * -1));
}

private _prevFrame = '';
private render() {
const frame = wrap(this._render(this) ?? '', process.stdout.columns, { hard: true });
if (frame === this._prevFrame) return;
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { KEYS } from './utils';

export type InferSetType<T> = T extends Set<infer U> ? U : never;

/**
* The state of the prompt
*/
export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error';

/**
* Typed event emitter for clack
*/
export interface ClackEvents {
initial: (value?: any) => void;
active: (value?: any) => void;
cancel: (value?: any) => void;
submit: (value?: any) => void;
error: (value?: any) => void;
cursor: (key?: InferSetType<typeof KEYS>) => void;
key: (key?: string) => void;
value: (value?: string) => void;
confirm: (value?: boolean) => void;
finalize: () => void;
}
49 changes: 49 additions & 0 deletions packages/core/src/utils/aliases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { InferSetType } from '../types';

const DEFAULT_KEYS = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const;
export const KEYS = new Set(DEFAULT_KEYS);

export const ALIASES = new Map<string, InferSetType<typeof KEYS>>([
['k', 'up'],
['j', 'down'],
['h', 'left'],
['l', 'right'],
['\x03', 'cancel'],
]);

/**
* Set custom global aliases for the default keys - This will not overwrite existing aliases just add new ones
*
* @param aliases - A map of aliases to keys
* @default
* new Map([['k', 'up'], ['j', 'down'], ['h', 'left'], ['l', 'right'], ['\x03', 'cancel'],])
*/
export function setGlobalAliases(alias: Array<[string, InferSetType<typeof KEYS>]>) {
for (const [newAlias, key] of alias) {
if (!ALIASES.has(newAlias)) {
ALIASES.set(newAlias, key);
}
}
}

/**
* Check if a key is an alias for a default key
* @param key - The key to check for
* @param type - The type of key to check for
* @returns boolean
*/
export function hasAliasKey(
key: string | Array<string | undefined>,
type: InferSetType<typeof KEYS>
) {
if (typeof key === 'string') {
return ALIASES.has(key) && ALIASES.get(key) === type;
}

return key
.map((n) => {
if (n !== undefined && ALIASES.has(n) && ALIASES.get(n) === type) return true;
return false;
})
.includes(true);
}
28 changes: 22 additions & 6 deletions packages/core/src/utils.ts → packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import type { Key } from 'node:readline';

import { stdin, stdout } from 'node:process';
import type { Key } from 'node:readline';
import * as readline from 'node:readline';
import type { Readable } from 'node:stream';
import { cursor } from 'sisteransi';
import { hasAliasKey } from './aliases';

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

export * from './aliases';
export * from './string';

export const CANCEL_SYMBOL = Symbol('clack:cancel');

export function isCancel(value: unknown): value is symbol {
return value === CANCEL_SYMBOL;
}

export function setRawMode(input: Readable, value: boolean) {
const i = input as typeof stdin;

if (i.isTTY) i.setRawMode(value);
}

export function block({
input = stdin,
output = stdout,
Expand All @@ -21,16 +37,16 @@ export function block({
readline.emitKeypressEvents(input, rl);
if (input.isTTY) input.setRawMode(true);

const clear = (data: Buffer, { name }: Key) => {
const clear = (data: Buffer, { name, sequence }: Key) => {
const str = String(data);
if (str === '\x03') {
if (hasAliasKey([str, name, sequence], 'cancel')) {
if (hideCursor) output.write(cursor.show);
process.exit(0);
return;
}
if (!overwrite) return;
let dx = name === 'return' ? 0 : -1;
let dy = name === 'return' ? -1 : 0;
const dx = name === 'return' ? 0 : -1;
const dy = name === 'return' ? -1 : 0;

readline.moveCursor(output, dx, dy, () => {
readline.clearLine(output, 1, () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/utils/string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function diffLines(a: string, b: string) {
if (a === b) return;

const aLines = a.split('\n');
const bLines = b.split('\n');
const diff: number[] = [];

for (let i = 0; i < Math.max(aLines.length, bLines.length); i++) {
if (aLines[i] !== bLines[i]) diff.push(i);
}

return diff;
}
Loading
Loading